From 828e5f1a1350332cae8cbf03fcc7a8285f7ca800 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:28:27 +0900 Subject: [PATCH 001/151] =?UTF-8?q?sync:=20docs=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=EC=BB=A4=EB=B0=8B(=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 5d5e37883..4cdf59a87 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -43,3 +43,24 @@ 28. 지제단가대비표에는 수량열이 빠지는게 맞음. 29. 원가검토 페이지의 상위탭과 하위탭의 구성을 하려고 함. 양식은 B08의 페이지 구성을 보면됨. 30. 원가검토 페이지의 탭구성, 일위대가(일위대가목록표, 일위대가표), 단가산출(단가산출근거목록표, 단가산출근거), 중기(중기목록표, 중기사용료, 중기시간금액집계표, 경비목록표, 경비수량금액집계표), 재료(재료비목록표, 자재단가목록표, 재료비수량금액집계표), 노무(노무비목록표, 노무비수량금액집계표)<<<지정안된 항목들은 상위탭임. + +## 추가 (182장 뒤 · 23번 보충) +31. 설계 내역서의 내용은 기본적으로 들어간 내용은 고정이 아님. 초기에 기본적으로 만드는 로직은 있지만 이걸 사용자가 수정할 수 있어야하며 추가하는 행과 삭제도 자유로워야함. 다시 말해 자유롭게 작성이 되고 단가 산출과 일위대가에 연결할 수 있어야한다는 것임. +32. 추가로 특정 셀의 텍스트 입력으로 마우스 우클릭이 브라우져 옵션이 보임. 우리프로그램에서는 우클릭으로 브라우져 옵션보이기는 없는 상태임.참고 +33. 총관설계 내역서에도 행들이 레벨 구분이 되어야함. +34. 박스보기가 구현되어 있는 버튼에 줄바꿈이 존재함. 1행으로 존재하게 변경하고 같이 존제하는 가로 개수 설정의 버튼들도 같은 행에 존재하게 해줘. +35. 자재단가대비표의에서 견적항목의 경우 단가를 수기입력하는 정보들만 견적서를 첨부하는 글자가 보이게 반영 +36. 자재단가대비표의 출처는 있어야하는 정보가 맞으나 현재 페이지에서는 중요한 정보가 아니니 출처정보는 마우스커서를 올린 셀의 정보를 보이게 하는데 숫자들의 의미를 모름 풀어서 짧게 정보를 보여줘. +37. 4번 요청에 이어 프로그램 전역에서 호버기능으로 추가정보를 보여줄때의 공용양식이 있었으면 함. +38. (7번 보충) 흙막이류 횡단도 형상 반영 문제_랩탑 메인 프로젝트 14+12측점임. 데이터 확인 +39. 종단그래프 상단 초기화버튼부터 쉬프트 상하버튼까지 우측 배수유역도와 새창 버튼과 사이즈를 유사하게 변경. 형태가 같을 필요는 없음. 필요한 경우 템플릿으로 만들어서 적용해도됨. +40. 종단페이지에서 배관지점을 하나찌고 다시 일반 측점을 찍은 상태에서 횡단으로 넘어가면 배관지점이 일반측점을 찍은 상태가 해제되고 직전 배관측점을 지정한곳으로 이동. 사용자가 최종선택한 정보를 가진 변수가 제대로 동작하는 확인 +41. 구조물중 여전히 노면공의 항목을 선택하는게 문제가 있음. + 1. 좌측 패널에서 구조물 리스트를 선택하는 경우 해당지점에 대한 전체측점 횡단도의 포현은 좋음 + 2. 이때 횡단도 내부에 구조물 형상도 하이라이트 되는건 좋음. + 3. 구조물 형상을 선택할때는 전체 측점들의 컨테이너와 구조물 형상 하이라이트 반영(현재 선택안됨) + 4. 이때 특정 하나의 횡단도를 선택한다면 구조물 리스트와 컨테이너 하이라이트를 끄고 선택된 횡단도를 기준으로 다시 표현되어야함. + 5. 여러 측점을 거치기때문에 하나의 측점 횡단도를 기준으로 삼기 어려움. 종단에서 표현 안되게 + 6. 단, 종단 세로선이나 횡단도를 선택할때는 해당 지점에 대한 하이라이트는 되어야함. +42. B06의 집수정 형상 선택에 대해 조금더 넓은 영역이 선택할 수 있게 변경(날개벽 선택 참고 가상의 선을 그려서 사각 형태의 가상의 영역을 선택영역으로 포함.) +43. B06 횡단도 좌측 패널에서 세부 컨테이너의 정보를 변경하면 해당 구조물 형상을 잘 선택되고 있는데 해당 패널의 컨테이너 영역을 선택해도 변경되게 해줘. From 818d7ec757da0661425d1d5424d9b4a842c0f3b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:11:38 +0900 Subject: [PATCH 002/151] =?UTF-8?q?sync:=20=EB=A1=9C=EC=BB=AC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=ED=8C=8C=EC=9D=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 18c1b1be2..9cd35f849 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -66,3 +66,10 @@ 43. B06 횡단도 좌측 패널에서 세부 컨테이너의 정보를 변경하면 해당 구조물 형상을 잘 선택되고 있는데 해당 패널의 컨테이너 영역을 선택해도 변경되게 해줘. 44. 랩탑 메인 프로젝트에서 38+9 측점 확인 종단의 선을 봤을때 중간에 안보이는 가상의 측점이 존재하는것처럼 보임. 데이터로 확인. (사진: 종단 계획선(보라 점선)이 고른 측점 세로선 근처에서 꺾임) 45. 지금 횡단도의 보라색선인 계획선과 관련인데 여기서 선을 찍으면 직선선 성분을 기준으로 이동을 할수 있게 만들어져 있임. 여기서 이동의 제약이 있어야함. 마음대로 변경되고 있음. 보라색 선은 선택은 크게는 성토측, 절토측, 도로측이 있음. 성토측 보라색 선을 선택할경우에는 도로 중심을 기준으로 성토방향으로 만 동작해야함. 만약 배관의 길이 영향으로 갈수 있는 방향은 절토측은 고정이 되고 성토측 방향의 구조물+성토선 전체가 이동이 되어야함. 반대로도 동일함. 이때 노면, 노견의 선을 선택한다면 Y방향으로 이동이되고 이때 종단도도 같이 동작해야함. 지금도 그렇게 구성되어 있음. 전반적으로 잘 구현되어 있으나 다듬을 필요가 있음. +46. 소단에서 소단위 위치값은 필요없음. 이동은 어차피 아래 위밖에 안되는 상태일테고 추가 기본지점을 도로하단 1m 지점에 추가해주면 사용자가 위치를 결정하게 해줘. 이때 성토사면 그룹의 이동과 충돌나는걸 피하기위해 좌우 이동이 없는거야. 결과적으로는 사용자가 지정한 요소들의 위치가 중요한거지. +47. 추가로 보라색 선 성분을 선택할때 나오는 텍스트는 사면 길이 > 길이로 변경 다음에 경사비율, 수평, 높이로 표현해줘. +48. (46번 보충) 소단을 선택됨을 활성화하는 영역은 횡단도의 소단 수평선 성분을 하이라이트 하면되고 패널과 컨테이너를 활성화하면 됨. 성절토의 X 이동과 와 도로 Y 이동, 소단의 높이 위치 이동을 명확학헤 분리해서 제어되어야하며 상하좌우 이동시 다른 선 성분으로 이동되는 경우가 있으니 조심 +49. B06 횡단도 제일산단의 쓸대없는 정보는 지우고 나오는 항목 이름을 줄여서 표현하고 전부 열림을 하면 공간을 많이 차지하니 내용을 보여주는 영역을 공유하는게 어떤지 +50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. +51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. +52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니 From add01ef5dea8dd0b76f3e80ba6d4ce31020b79c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:11:52 +0900 Subject: [PATCH 003/151] =?UTF-8?q?chore(sync):=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EB=AF=B8=EB=B0=98=EC=98=81=20=EC=88=98=EC=A0=95=EB=B6=84=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20(=EC=9B=90=EA=B2=A9=EA=B3=BC=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=20=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 9cd35f849..c6bdce7b4 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -73,3 +73,34 @@ 50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. 51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. 52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니 +53. B09의 박스보기 버튼과 가로 숫자 버튼, 그리고 그위의 계산들에 대한 안내는 같은행으로 변경 +54. 박스보기의 경우 지정된 카드 행들은 높이를 같게 변경 +55. 설계내역서의 공종 및 명칭의 글자들은 좌측 맞춤이고 좌측에 들여쓰기로 레벨이 표현되는게 맞아보임. 그리고 레벨 제어를 어떻게 하는지 모르겠음. +56. 박스보기에서 제목과 합계 정보열의 색은 다른색으로 지정하는게 테마고려하여 선택 +57. B08/B09의 테이블 양식이 에서 레벨이 필요한 정보들은 설계내역서의 레벨지정 양식을 따라가되 제어가 안되게 반영하면 일원화되지 않을까? 그리고 셀에 같은 값으로 표현되는 행들은 결국 병합인것같은데 맞지 않아? +58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) +59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 +60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) +65. B06의 상단의 성토 5m 이상, 포장 권장, 소단 검토에서 버튼의 형상을 사각 라운드로 변경하고 누를시 나오는 버튼들은 현재를 유지, 그리고 메인 페이지의 스크롤이 제일 상단에 있을경우 상단 버튼으로 내역을 열면 페이지가 아래로 밀림. 안밀리게 변경, 그리고 항목을 선택하면 이동하면서 해당 버튼셋은 접힘으로 그리고 만약 선택된 상태에서 다시 열면 하이라이트 기능, 다시 말해 사용자가 측점 횡단도를 누른상태에서 상단 3버튼을 누르면 하이라이트 되게 변경 +66. M01의 단가산출로직들의 항목에 신규열인 별칭 열이 필요함 설계자들이 찾을때 사용하는 명칭과 다른 경우가 있음. 이를 보완하기 위해 별칭열을 추가해줘. 내가 별칭을 있을수도 있고 없을 수도 있지만 값은 내가 넣을거야. 그리고 이때 프로그램 전역에서 단가산출 관련 검색은 별칭도 같이 검색이 되게 해야함. + +## 셋째 붙임 (10-10 오후) +67. B06 횡단도 상세 모달 — 셀에 지정된 면적과 길이값을 선택하게 하였는데 안잡히는 경우가 있어 개선이 필요함. 상세 모달창에서 선들이 교차되는 지점들을 기준으로 선들이 분할되어야함. 그리고 선택할때는 다중 선택을 하면 원지반도 가능해야함. 그럼 문제 없어보임. 그럼 면적에서도 폐쇄 영역을 지정하기 좋아보임. 추가로 선택영 지점의 면적과 선들의 길이의 값을 보는 기능이 있었으면 함. 이는 메인페이지의 기능이 있으니 확인, 잘 반영되었다면 메인페이지에도 구현한 생각임. +68. 간혹가다 B06의 상단에 누락된 구조물 측점이라고 측점추가 버튼이 나오는데 이유는? (조사) +69. 관보호공 유입 측 흙막이의 경우에 기본 높이를 배관 + 0.5m로 지정되어 있을것임. 이걸 0.7m로 변경 +70. 도수로/돌붙입의 구조물 형상을 잡고 이동하는 경우 토스트 메세지로 '기준측점을 옮겼습니다 ....' 나오는데 나는 도수로와 돌붙임의 이동에 대해 요청한건 없음. 왜그런지 분석하고 알려줘. (조사) +71. B07 (기반영요청사항) 횡단도의 격자에 배치를 옮기기 위해 선을 선택할때 원지반선이 안잡힘 +72. B07 (기반영요청사항) 추가로 레이어별로 측점의 원지반선과 설계선을 잡을 수 있는 레이어 자동 생성 +73. B07 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼 기능은 좋은데 뒤에 검은 격자선이 하나더 있음. 확인 후 삭제 +74. B07 횡단도 표준 옵션의 버튼들이 양식화와 일원화 안되어 있음. 확인후 정리할 수 있는 양식으로 적용 +76. 다음으로 중요한 문제인데 특정 측점에 2개의 측점이 숨어 있는 경우가 있음. 엄상준_검증 프로젝트에서 13+3 지점에 대해 이유를 확인해줘. 원인파악을 제대로 할것 계속 반복적으로 동일한 문제가 발생됨 +77. (제안 · 의견 물음) 버튼으로 조작하여 종단을 만드는 기능은 사용자들이 어려워 하는것같아. 직선화버튼 우측에 자유지정 버튼을 선택하면 먼저 범위를 지정(2개의 세로선 · 영역 하이라이트) · 시작이나 종료 세로선 근처 클릭 = 연결된 상태로 시작 · 직선 > R > 직선 > R 형태로 클릭할때마다 연결. 어떻게 생각함? +78. 캐드 프로그램에서 되돌리기와 다시하기 기능이 없는거야? 아니면 구조물조합의 캐드의 내용에 국한된 문제인거야? +79. (77번 보충) 1-1 아마 사용자가 선택하는건 자유이지만 아마 세로측점선을 기준으로 하되 높이값을 받아야 되고 때 확정은 아니니 실시간으로 점선으로 보여주면 좋을것같아. +80. 횡단페이지에는 횡단배수와 일반측점의 종단 세로선택에 따라 좌측 구조물 배치 패널이 잘 작동하는데 B05에서는 잘안되는데 이유를 확인해봐. +81. B06 페이지의 제일 상단 경고 버튼들과 횡단도 컨테이너 공간이 여유가 조금 많은것같아. 줄여줘. +82. 신규기능으로 B05 ~B09 페이지에 마우스 우측 클릭 오래누르기와 z키 누르기로 마우스 커서를 따라가는 돋보기 기능이 있으면 해. 범위와 배율은 넣거 일단 지정해줘. From efb5f246d9a8f6f3830194c7de83d022bd07c6fb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:53:03 +0900 Subject: [PATCH 004/151] =?UTF-8?q?sync:=20=EC=9E=84=EC=8B=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(=EB=AA=A9=ED=91=9C=EC=99=80=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index cd0af97fd..2a930d328 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -106,3 +106,18 @@ 82. 신규기능으로 B05 ~B09 페이지에 마우스 우측 클릭 오래누르기와 z키 누르기로 마우스 커서를 따라가는 돋보기 기능이 있으면 해. 범위와 배율은 넣거 일단 지정해줘. 83. 돋보기 구현 완료, 돋보기가 활성화된 상태에서는 스크롤로 배율을 조절하게 단 화면 스크롤은 안되게 막기, 돋보기의 크기는 지금크기의 50% 증가 84. 돋보기의 기능은 M01, M02, B01 ~ B09까지로 적용해줘. +85. 오토캐드 영역은 돋보기 필요없음 + +## 넷째 붙임 (10-10 저녁) +86. M01 GF000156 의 모래단위중량, 자갈단위중량은 밀도값으로 보이는데 기본값이 있을 것으로 판단됨(or 현재 12-1-2의 원문을 조사하면 역산하여 밀도를 뽑을 수 있지 않음?). 원문 조사 +87. 엄상준의 SU-000007의 구조물 조합의 일위대가 중 기계비빔타설을 보면 시멘트, 모래, 자갈에 대한 원가들이 잡혀있으며 이때 단위가 잘못된걸로 판단됨. 확인, 추가로 하단의 자재 목록을 보면 별도계상이 나오게 되고 이때는 상단의 일위대가에 단가가 빠져야되는 상황으로 보임. 문제는 2가지임. 분리해서 검토. 일위대가의 단가산출 항목 중 상세내용의 내역을 자세히 검토하기 위해 자재목록으로 자동연결한 것 뿐이니 별도로 보면 안됨. +88. 기계비빔타설의 경우 원문참고가 12-1-2와 12-1-3 같이 되어야하는걸로 보이는데 하나만 보임. +89. (질문) 시멘트는 어떻게 연결되어 나온 정보임? +90. 구조물 조합의 자재에서 별도계상을 선택하면 선택된 자재품목을 사라져야함. 이건 나중에 본프로그램에서 별도로 계상할 생각임 +91. B05/B06 좌측 패널 — 구조물배치의 구조물 조합연결 모달창에서 M02의 구조물조합 페이지의 상단 테이블도 표현 +92. B06 횡단도 — 세월교와 배관 자리의 경우 배관정보를 텍스트로 표현해야되는데 이때 노폭과 노견의 길이정보를 보여주는 라벨 위로 이동해줘. +93. B06 — 관보호공의 위치제어의 경우 관 길이에 맞춰 이동하다보니 자유도가 떨어짐. 관 길이를 무시하고 0.1m 단위로 이동이 되게 반영, 단, 관길이 정보를 나타내는 라벨과 좌측 패널에서는 정수값으로 사사오입하여 작성하고 추가로 괄호를 추가하여 소수점 1째자리까지 표현, 이때 구조물 집계표로 정보를 전달할때는 사사오입한 정보를 전달 +94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가 +95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) +96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 +97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 From 21bd62b18a9134ef870e915c86040d6ab4756cf6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:55:32 +0900 Subject: [PATCH 005/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20150?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index a2ae14ba1..a4e2db160 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -178,3 +178,4 @@ 147. (브레인 물음 답 · 원문) 1. 백엔드 재시작 2. 폴더만 기준이 되면 됨. 3. 반영 4. 테이블이 아닌 항목은 대상이 아님 5. 다음으로 공사지 +로 추가하는 항목에서 나오는 모달창의 크기를 조금 더 키우고 내부 내용을 정리할 생각임. 항목은 분류(기준위치, 거래처) 항목, 거래처 이름, 목적지를 2열배치 이때 분류에 따라 항목이 바뀜(다이어그램의 기준점+ 버튼은 삭제), 거래처 위치를 소재지로 변경하고 직접입력버튼은 삭제. 전부 주소찾기나 지도에서 찾아야함. 등록에 이제 다음지도로 대체 6. 메일은 내가 알아서 하겠음. 7. 데스크탑은 세션 확인하면 살아있음. 오르카 리스트 확인 8. 추가 거리이정표 항목 - 등록으로 보이는 모달창은 해당 컨테이너를 눌렀을 때 같이 보여주면 됨. - 길찾기는 연결선을 누르면 보이는 모달창에 추가 이때의 항목은 도로 구분과 거리를 항목값으로 받고 하위에 다음맵으로 경로를 보여주면 되는데 기준점이 발생되는 이유는 임도 특성상 도로가 없는 경우 한 지점을 지정하게 됨. 다시말해 기준점이 있다면 기준점과 거래처의 다음맵지도가 필요함. 이때 위치조절을 위해 위치이동은 되어야함. 시점과 기준점은 도로가 연결이 안되는 경우가 있어 수기 입력이 되어야할때도 있음. 시점과 중점은 항상 거리는 수기입력임. 이때 도로 구분의 경우 2차 포장이란 시점이 아니라 기준점이 있을때의 경우임. 다시말해 기준점을 거쳐 시점에 들어가는 경우에만 존재하는 열 항목임. 기준점이 없다면 그냥 포장이 됨. 148. 길찾기 그림이 없는 이유는 뭐야? (브레인 판정: 서버 안에서 헤드리스 브라우저를 못 띄움 — uvicorn 이 쓰는 Windows Selector 이벤트 루프는 하위 프로세스를 못 만들어 Playwright NotImplementedError · 시험은 서버 밖이라 통과) 149. 고침이라는 명칭을 수정으로 변경. 컨테이너 항목에서 + 줄이 있는 이유가 뭐야. 거래처 등록은 거래처에 관련 정보를 선에는 경로관련을 묶어서 표현하려고 하는거잖아. 그리고 기준위치에는 항목이 필요없어. 거쳐가야하는 지점이다보니 의미가 없어. 이번 부터는 너가 직접작업해. 거리이정표의 경우에는 폭을 브라우저에 맞춰서 폭 전체를 사용하게 하고 하단의 테이블도 동일하게 폭에 맞춰 열의 사이즈를 조절해줘. 그리고 현재 다이어그램이 줌아웃이 너무 멀어서 글자가 잘안보여 적당히 줌인해줘. +150. 기준점과 선의 컨테이너 내부 카카오맵의 영역의 높이를 더 높여줘. 카카오맵의 높이는 어디서든 같게 해줘. From 0740592b7bbcf3c622317aa696d109de350ecc03 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:56:52 +0900 Subject: [PATCH 006/151] =?UTF-8?q?sync:=20=EC=9E=84=EC=8B=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(=EB=AA=A9=ED=91=9C=EC=99=80=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/plans/2026-10-10_plan_221-227.md | 5 ++ docs/raw/요구_1010_2/사용자요구_1010_2.md | 57 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/docs/raw/plans/2026-10-10_plan_221-227.md b/docs/raw/plans/2026-10-10_plan_221-227.md index 0dc8f340a..27829b4a7 100644 --- a/docs/raw/plans/2026-10-10_plan_221-227.md +++ b/docs/raw/plans/2026-10-10_plan_221-227.md @@ -42,3 +42,8 @@ - 검증완료 — test_m02_card_shell · test_modal_top_and_add_word 14 passed · test_200_2 4 passed · typecheck 통과 · 코드 글만 고침(Bill_Level 안내 「더하기」→「추가」) - [x] 225-2 (sub1) test_79 관 보호공 기본 자리 d(유입) 0.6 → 0.4 로 바뀜 — 211(유입 흙막이 관 + 0.7)의 당연한 결과인지 · 흠인지 판정 · 결과면 기대값 · 흠이면 고침 — 시험 통과 - 검증완료 — 판정 = 당연한 결과(흠 아님): 벽 바닥은 관 invert 상한(노견 − 관경 − 토피 0.5)에 고정 · 벽이 0.2 높아져 윗면 노견 − 0.5 → − 0.3 · d = 올림(1.2 × (1.5 − 1.2) · 0.1) = 0.4 · 225-1 관 길이와 무관 · 기대값 d 0.4 · 윗면 노견 − 0.3 으로 고침(시험 글에 까닭) · test_79 · 211_1 · 225_1 27 passed · typecheck 0 + +## 228. 표 레벨 = 들여쓰기에 따라 레벨 · 글꼴(크기 · 굵기) 자동 (sub3) +정함(브레인) = 사용자 99 · 100번 · 200-2 에서 글자 크기를 한 벌로 맞춘 것은 브레인 판정 잘못(23번 원문 「들여쓰기와 폰트 · 사이즈로 구분」) → 되살림 · 공용 표 level 옵션(201)에서 레벨마다 글꼴 자동: Lv1 16px 굵게 · Lv2 15px 굵게 · Lv3 14px 보통 · Lv4 13px 보통 · Lv5 12px 보통 + 들여쓰기 1em/레벨(줄 높이 28px 안) · 설계내역서는 들여쓰기 · 내어쓰기 · 끌기 · 방향키로 부모가 바뀌면 그 자리에서 레벨 · 글꼴이 바로 바뀜(다시 그림 확인) · 보기 전용 표(총괄 · 원가계산서 · 일위대가표 등)도 같은 글꼴 규칙 · 조사 AE(외부 사례) 결과로 덧붙일 것(접기/펴기 등)은 따로 +- [x] 228-1 레벨 글꼴 자동 · 들여쓰기 = 레벨 바로 반영 — test_200_2 · 201_1 · 204_1 · 182 고침 · 새 시험 + - 검증완료 — test_228_1 새 6 + 200_2 · 201_1 · 204_1 · 182 · 184_3 · 185_1 = 37 통과 · typecheck 0 · 굵게 = 묶음(브레인 답 A) · 내어쓰기 g2 Lv4→3 · 자손 Lv5→4 · 들여쓰기로 자식 생긴 항목 = 굵게 · 커밋 833e4146a diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 2a930d328..a2ae14ba1 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -121,3 +121,60 @@ 95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) 96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 +98. 왜 프로젝트의 B06의 좌측패널의 구조물 리스트에서 노면공의 구조물 범위에 들어온 측점들중 다른 구조물의 리스트를 선택했음에도 노면공의 횡단면도 전체가 선택되는거야. 일단 조사만 진행해줘. 코드 구현은 나중에 할래 +99. 테이블 양식에서 설계내역서의 레벨 기능이 제대로 구현안되어 있음. 외부에서 어떻게 해야하는지 확인해봐. (조사) +100. (99번 보충) 테이블에서 제대로 안된다는 건 결국 들여쓰기에따라 레벨이 지정이 되고 폰트들도 자동으로 변경이 되어야하는데 안되고 있잖아. 그냥 들여쓰기만 하는거 아니야? +101. B09의 설계내역서 탭은 지금 하위탭이 없음. 여기에 보조탭 공간을 활용하여 행의 들여쓰기, 내어쓰기, 위, 아래 버튼들을 넣어줘. +102. B05에서 종단 새창으로 띄우고 브라우져 최대화한경우 마우스 스크롤로 종단을 움직이는 기능이 작동이 안됨. 최대화를 안하면 작동됨. +103. 노면공 범위 관련은 1번 방향이 더 좋을것같아. 대신 하이라이트는 여러군데 연동되어 있어 좀더 폭넓게 조사해보고 작동해줘. +104. 3D의 측점의 노선을 선택할때 하이라이트되는데 이게 바로 꺼지는 상황이야. 일단 하이라이트가 계속 문제되는데 전체적인 조사가 필요해. 일단 조사부터 해줘. (조사) +105. 추가로 하이라이트에서 소수점 단위 구조물 측점들의 경우 1단위로 사사오입하여 측점을 지정하게 반영했던걸로 기억하는데 언제 했고 어떤식으로 했는지 알아보고 어떻게 개선할지 조사만 해봐. (조사) +106. (105번 보충) 일반측점이 있고 소수점 단위로 근처의 측점이 있다면 입력되는 기준의 측점을 값을 정수단위로 맞춰야하는게 아니냐는 거야. 선택이 안되는게 문제가 아니라 사용자가 소수점을 못넣게 하고 넣더라도 정수로 변경하여 맞출수 있는 기능이 필요하지 않을까해서 조사를 시킨거야. 조사만 해. +107. (질문) 노면공이 아닌데 범위값을 지정하는 구조물이 뭐가 있는거야? — 답: B 측구 · 산마루측구 · 소단측구 · 도수로·산비탈수로 · 절토사면 배수로 · 맹암거 / C 옹벽 · 돌쌓기(찰 · 메) · 흙막이 · 큰돌쌓기 · 소단 / E 대피소 · 정차·작업장 · 가드레일 / F 비탈면 녹화 +108. 셋다 너의 제안대로 해주고(104 구멍 둘 먼저 · 고름 한 칸 원천으로 단계별 묶음 · 구조물 기준 측점 정수 맞춤) 노면공은 아닌데 범위를 갖는 구조물은 예전의 자료인것같은데 지금 구현하는 구성을 다시 확인해봐. +109. 추가로 소단에 대한 측점 표기가 3D에서 측점 표현이 안되는것같은데 현재 구조물 리스트에서 내용과 현재 구조물군과 종류의 내용 중 누락된게 뭐가 있는지 조사해봐. (조사) +110. (질문) 지금 조사를 하고 있는 너의 별도 에이전트가 있어? +111. B09에서 설계 내역서의 내용중 공종, 규격, 수량 단위의 값이 텍스트입력을 바로 받게 되어 있어서 드래그가 힘들어. 더블클릭하여 입력을 할수 있게하면 드래그에 문제없을것같아. 그리고 해당 행의 하이라이트는 박스가 쳐지게 바꾸는게 좋을것같아. +112. B09의 테이블 행을 기준으로 항목을 레벨화하는게 어려운거야? 접히지 않는 트리라고 생각하면 쉽잖아. 지금 구조물조합의 마스터/회사/개인도 다 트리형태인거고 그럼 중간 폴더들은 당연히 값이 아니라 수식이들어와야하고 파일이되는 최하위 항목을 입력을 받게 하는데 이때 일위대가나 단가산출을 연결하게 하면되는거고 +113. 추가로 B08과 B09의 내용중에서 아래 설명때문에 페이지 스크롤이 동작해야하는 경우가 있어. 그냥 제작 설명을 제외하고 내용을 줄여주고 페이지에 스크롤이 동작하지 않게 해줘. + +## 다섯째 붙임 (10-10 저녁) + +114. 엑셀 프로그램에서 글 입력의 셀 맞춤 방식은 상하, 좌우 둘다 가운데 값을 기본값으로 변경 +115. 저장때 측점이 지워지는 구멍은 막을것. +116. 나머지는 일단 보류 (조사 AG 3D 표기 범위 · B06 꺼진 종류 · 표 레벨 번호 칸 · 되돌리기 · 로직 기본값 · 제비율 · 관급 등) +117. 앞으로 설명할때는 어느 페이지의 어떤 영역인지 말할것. 설명이 이해안됨. +118. B09의 총괄설계내역서는 내용중에 순공사비를 뽑기 위해 설계내역서의 레벨 2까지의 내용을 총괄설계내역서의 상단에 작성하는 내용이 빠져있음. 다시말해 레벨 2까지의 집계 내용을 상단에 정리해서 넣기만 하면 되고 해당항목을 선택하였을때 설계내역서를 이동하고 붉은색으로 하이라이트 하면 됨. +119. 설계내역서의 폴더구조는 좋은데 레벨에 따라 폴더가 되고 폴더가 되면 폰트를 같은 레벨의 다른 것들과 같은 폰트 사이즈와 색상을 선정하는게 어려운거야.? 너가 직접 확인하고 코드작업해줘. +120. B08의 에서 메인탭으로 거리이정표를 만들어주고 하위탭으로 거리이정표 하나더 추가 거리에 따라 별도로 추가 탭이 생성이 되면 됨. 생성되는 기준으로는 거리 이정표를 구현하여야함. (첨부 = 거리이정표 견본: 태원 · 주상기업 · 거창군청 · 병곡리 968-31 · 시점 · 중점 · 김천역 상자와 도로구분 km, 아래 표 = 구분(장비 거창군청→시점 20톤 · 8톤 / 혼합골재 골재원→임도중점 / 전석운반 골재원→임도중점 / 철근 김천역→임도중점) · 도로구분(2차포장 · 1차포장 · 비포장(1)) · 계(포장 · 비포장 · 합계) · 비고) 기본적으로 시점의 위치는 공사지의 시작 위치임. 중점은 사용자가 거리를 입력하는 위치 이두개는 하나의 컨테이너로 묶어서 공사지가 되고 공사지를 기준으로 특정 기준점이 있을수도 있고 없을 수도 있음. 이는 첨부사진의 병곡리가 됨. 특정기준점이나 공사지를 기준으로 거래업체를 다음 지도 API로 맵을 보여주고 길찾기를 하여 거리를 확보하고 이때 거리찾기한 항목을 각각의 하위탭으로 컨테이너 하나에 사진하나씩 배치해주면 됨. 여러개가 있을수 있으니 추가와 삭제 버튼은 있어야함. +121. B09의 설계내역 테이블을 확인했는데 제어가 어려웠던 이유는 레벨이동의 앞에 들여쓰기가 너무 좁아서 적당한 위치에 놓기 어려웠던것같아. 그러니 들여쓰기 폭을 조금 더 띄워주고 추가로 어디에 추가되는지 어디가 들어가는지가 불분명해. 조금더 잘보이게 표시해줘. +122. 추가로 B09의 좌측패널에서 데이터 점검 컨테이너와 업데이트 버튼 사이가 너무 협소해. 업데이트 좌측 여유만큼 상단에도 여유가 있었으면 함. +123. 다음으로 추가로 B08과 B09의 테이블의 내용중에 사용자가 입력가능한 셀영역을 셀색상을 바꾸는데 이것대신에 셀 외곽부에 테두리를 넣게 변경해줘. +124. 입력셀 테두리 색상선정할때 블랙과 화이트테마를 잘 고려해줘. +125. B09에서 총괄설계내역서의 내용을 선택하게 되면 설계내역서의 해당항목을 하이라이트 되는데 이때 하이라이트는 해당 항목의 하위 내용까지 같이 범위로 지정해줘야해. 다음으로 그냥 행선택과 하이라이트가 중복으로 표기되는데 하나만 표기되게 해줘. 하이라이트 되었더라도 다른 행을 선택하면 하이라이트가 취소되어야하고 이전에 선택된 행이 있더라도 총괄설계내역서에서 선택하면 선택행이 취소되고 하이라이트되어야함. +126. 다음으로 B08과B09의 테이블 내용에서 수기입력 방식은 더블클릭으로 변경해줘. 모든 값들은 쉽게 변경되면 안되거든. +120-답(조사 AH 물음 넷 답 · 원문). ① 경로 그림 = 「일단 브이월드에서 길찾기가 있는지 몰랐네? 있다면 브이월드도 좋아 벌써 API가 연결되어있거든.」 ② 도로 구분 = 「모두 사용자가 선택하게 해줘. 기점별로 어차피 드롭다운 메뉴를 지정하면 되지 않을까?」 ③ 표 구분 = 「구분에서 첫번째열은 항목: 수기입력, 2번째열은 업체이름: 수기입력, 세번째 열은 목적지임. 사용자는 다이어그램으로 표현된 컨테이너를 이용하여 추가수정할수 있게 하되 대신 방향은 기준점을 기준으로 지도상의 방위를 나타냄. 항목을 추가할때는 구분정보와 지도상의 위치값과 길찾기 값을 받아와야함.」 ④ 중점 = 「사용자가 입력할수 있게 해줘. 기본값은 도로의 중간지점으로 기본값으로 하고 사용자가 지정하게 하자.」 +127. 다음으로 공사원가계산서의 테이블에서 병합기능을 사용하였는데 행의 마우스 호버에 연동된 하이라이트 기능이 형태가 복잡해지고 있어. 개선해줘. +128. (B09 설계내역서 표 화면 붙임 · 입력 칸 보라 테두리) 테이블에 셀테두리를 추가했는데 구분에 전혀 도움이 되지 않고 있어. 일단 테두리는 원래대로 돌려주고 다음으로 새 공종추가, 아래 줄, 위에 줄 추가들은 하나로 항목 추가로 하자. 어차피 마우스 우클릭 후 해당 위치를 기준으로 그냥 하나를 넣어주면 되는거야. 어차피 위치에 따라 결정되니 없어도 문제 없어보여. 연결에서는 서고나 (마스터 / 회사/개인)이라는 이름을 삭제해줘 쓸데없어. (M01도 새산근도 동일함.) 구조물 조합, 일위대가 사전조합, 단가산출 이렇게만 표현해줘. 줄지우기는 줄 삭제로 변경 +129. 다음으로 마우스 우클릭 같은 패널에 항목을 추가하여 보여주는데 그냥 우측에 신규 페이지로 연결 5개 항목을 보여주면 됨. +130. (거리이정표 고침 · 원문) 1. 공사지의 + 버튼으로 나오는 모달창을 이용하여 항목, 업체이름 목적지를 받음. 방위항목은 지도상에 지정한 위치에 따라 자동배치되는 항목임. 하단에는 주소검색 기능을 구현과 등록완료시 지정된 맵을 보여주는 기능 구현. 프로젝트 등록에 관련 기능있음. 재활용, 시점을 컨테이너를 누르면 모달창으로 프로젝트 등록할 당시의 주소와 브이월드 주소와 하단에 맵을 보여줌. 2. 공사지의 (끊김) 3. 다이어그램의 영역은 팬과 줌인 동작이 되어야함. 동작과 관련해서는 공용 양식 있음. 확인 4. 보조탭으로 별도로 추가버튼 필요 없으며 탭의 내용은 거리 길찾기를 한 맵의 결과가 필요함. 구간, 표줄 이런건 필요없음. 만약 길찾기의 경우 안된다면 별도의 창에서 다음맵을 연결하여 길을 찾게하고 해당 브라우저를 확인시 캡처하게 하도록 변경 5. 컨테이너를 누를경우 모달창으로 관련정보를 보여줌. 페이지 이동 아님 +130-6. 거리와 도로 구분 정보는 다이어그램에서 바꿀수도 있게 변경 +131. 다음으로 구조물 집계표에서 해당측점의 행이 구조물조합들이 전부 연결이 된 상태라면 녹색램프 표현, +132. 거리이정표의 추가내용 - 테이블의 폭은 상단 다이어그램과 동일한 폭을 유지 - 다이어그램의 내부 내용에는 항목도 추가 - 좌측패널의 거리이정표 패널은 필요없음. 다이어그램에서 직접 수정 - 다이어그램 팬 동작 구현안됨. +133. 업체라는 말보다는 거래처라는 말이 맞아보임 +134. 다음을 별도 브라우저 탭이 아닌 내부 모달창 내부에서 실행 안됨? 그래야 캡처가 쉬울것같음. (브레인 확인: map.kakao.com 길찾기 링크가 iframe 안에서 열림 · 경로 · km 보임) +135. 추가로 다이어그램의 영역에서 스크롤이 존재함. 줌인아웃에 방해됨. 스크롤 안되게 해줘. +136. (길찾기 방식 결정) 일단 카카오로 가야할것같아. 카카오 디벨로퍼 페이지에 와있는데 제휴방법을 알려줘. +137. (경로 그림 방식) 가로 가는게 좋겠어 — 서버가 카카오 길찾기 경로 선을 브이월드 지도 위에 그려 자동 저장. 메일에도 거리와 경로데이터라고 해야하는거 아니야? +138. (조사 더 · 너무 보수적 아님?) 가 사안은 배제해. 데이터가 다른 맵위에 맞을리가 없어. → 나(카카오 지도 JS 위 경로 + [캡처])로 +139. 그럼 캡처는 백엔드에서 하고 그림만 가져오는건 어때? (브레인 시험: 서버 헤드리스 Edge + 카카오 지도 JS + 길 선 → 1200×800 PNG 2.4초 성공) +140. 테일스케일 IP는 (카카오 JavaScript 도메인에) 등록했어 참고해 +141. 다음으로 구조물 집계표에 구조물조합 연결이 완료된 항목은 녹색 램프를 넣어달라고 했는데 없어. (화면 붙임 · 노랑만 · 247-1 이 sub7 에 안 들어갔던 것 — 다시 보냄) +142. 다음으로 거리이정표의 다이어그램을 보여줄때 적당히 넓은 영역을 배경영역으로 지정되어야할것같아. 초기에는 당연히 공사지 컨테이너만 있을텐데 이러면 어떤 항목이 들어와도 다시 줌아웃을 해야하잖아. +143. 시점과 중점도 연결선이 존재해야하고 시점과 중점컨테이너에도 작은 글씨로 주소와 좌표값을 보여주는게 좋을것 같아. +144. 거래처 컨테이너의 경우 항목, 거래처, 주소를 보여줘. +145. 추가로 거리이정표에는 선들과 컨테이너들의 공간을 여유를 조금 두는게 좋겠어. 자꾸 침범을 하네. +146. 추가로 누락된 업무가 있는지 확인해봐. +147. (브레인 물음 답 · 원문) 1. 백엔드 재시작 2. 폴더만 기준이 되면 됨. 3. 반영 4. 테이블이 아닌 항목은 대상이 아님 5. 다음으로 공사지 +로 추가하는 항목에서 나오는 모달창의 크기를 조금 더 키우고 내부 내용을 정리할 생각임. 항목은 분류(기준위치, 거래처) 항목, 거래처 이름, 목적지를 2열배치 이때 분류에 따라 항목이 바뀜(다이어그램의 기준점+ 버튼은 삭제), 거래처 위치를 소재지로 변경하고 직접입력버튼은 삭제. 전부 주소찾기나 지도에서 찾아야함. 등록에 이제 다음지도로 대체 6. 메일은 내가 알아서 하겠음. 7. 데스크탑은 세션 확인하면 살아있음. 오르카 리스트 확인 8. 추가 거리이정표 항목 - 등록으로 보이는 모달창은 해당 컨테이너를 눌렀을 때 같이 보여주면 됨. - 길찾기는 연결선을 누르면 보이는 모달창에 추가 이때의 항목은 도로 구분과 거리를 항목값으로 받고 하위에 다음맵으로 경로를 보여주면 되는데 기준점이 발생되는 이유는 임도 특성상 도로가 없는 경우 한 지점을 지정하게 됨. 다시말해 기준점이 있다면 기준점과 거래처의 다음맵지도가 필요함. 이때 위치조절을 위해 위치이동은 되어야함. 시점과 기준점은 도로가 연결이 안되는 경우가 있어 수기 입력이 되어야할때도 있음. 시점과 중점은 항상 거리는 수기입력임. 이때 도로 구분의 경우 2차 포장이란 시점이 아니라 기준점이 있을때의 경우임. 다시말해 기준점을 거쳐 시점에 들어가는 경우에만 존재하는 열 항목임. 기준점이 없다면 그냥 포장이 됨. +148. 길찾기 그림이 없는 이유는 뭐야? (브레인 판정: 서버 안에서 헤드리스 브라우저를 못 띄움 — uvicorn 이 쓰는 Windows Selector 이벤트 루프는 하위 프로세스를 못 만들어 Playwright NotImplementedError · 시험은 서버 밖이라 통과) +149. 고침이라는 명칭을 수정으로 변경. 컨테이너 항목에서 + 줄이 있는 이유가 뭐야. 거래처 등록은 거래처에 관련 정보를 선에는 경로관련을 묶어서 표현하려고 하는거잖아. 그리고 기준위치에는 항목이 필요없어. 거쳐가야하는 지점이다보니 의미가 없어. 이번 부터는 너가 직접작업해. 거리이정표의 경우에는 폭을 브라우저에 맞춰서 폭 전체를 사용하게 하고 하단의 테이블도 동일하게 폭에 맞춰 열의 사이즈를 조절해줘. 그리고 현재 다이어그램이 줌아웃이 너무 멀어서 글자가 잘안보여 적당히 줌인해줘. From f57c18ee10180a874ecd4e52f20703f92e635fe9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:57:04 +0900 Subject: [PATCH 007/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20151?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index a4e2db160..f023bc187 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -179,3 +179,4 @@ 148. 길찾기 그림이 없는 이유는 뭐야? (브레인 판정: 서버 안에서 헤드리스 브라우저를 못 띄움 — uvicorn 이 쓰는 Windows Selector 이벤트 루프는 하위 프로세스를 못 만들어 Playwright NotImplementedError · 시험은 서버 밖이라 통과) 149. 고침이라는 명칭을 수정으로 변경. 컨테이너 항목에서 + 줄이 있는 이유가 뭐야. 거래처 등록은 거래처에 관련 정보를 선에는 경로관련을 묶어서 표현하려고 하는거잖아. 그리고 기준위치에는 항목이 필요없어. 거쳐가야하는 지점이다보니 의미가 없어. 이번 부터는 너가 직접작업해. 거리이정표의 경우에는 폭을 브라우저에 맞춰서 폭 전체를 사용하게 하고 하단의 테이블도 동일하게 폭에 맞춰 열의 사이즈를 조절해줘. 그리고 현재 다이어그램이 줌아웃이 너무 멀어서 글자가 잘안보여 적당히 줌인해줘. 150. 기준점과 선의 컨테이너 내부 카카오맵의 영역의 높이를 더 높여줘. 카카오맵의 높이는 어디서든 같게 해줘. +151. 그리고 선 성분의 연결정보는 컨테이너들을 드래그해서 연결하고 이정보를 가져오는건 어때? (제안 · 물음) From d70b66f57ee4980d2cbf22e35753b1ce1410576d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:08:57 +0900 Subject: [PATCH 008/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=E2=80=94=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EA=B8=80=20=C2=B7=20=EC=83=81=EC=9E=90=20=ED=95=98=EB=82=98=20?= =?UTF-8?q?=3D=20=ED=95=AD=EB=AA=A9=20=ED=95=98=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=ED=8F=AD=20=EC=A0=84=EC=B2=B4=20=C2=B7=20=EC=B2=98=EC=9D=8C=20?= =?UTF-8?q?=EB=B0=B0=EC=9C=A8=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EB=86=92=EC=9D=B4=20=C2=B7=20=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=3D=20=EC=84=A0=20=EC=9E=90=EB=A6=AC=20(25?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」 - 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤 - 카카오 지도 높이 어디서나 480px - 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Milestone_Calc.py | 39 ++++---- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 66 +++++++++---- B08_Quantity/B08_Quantity_UI_Milestone.css | 26 ++--- B08_Quantity/B08_Quantity_UI_Milestone.ts | 12 +-- .../B08_Quantity_UI_Milestone_Diagram.ts | 77 ++++++++++++--- .../B08_Quantity_UI_Milestone_LegModal.ts | 32 ++++--- .../B08_Quantity_UI_Milestone_Modal.ts | 94 +++++++------------ .../B08_Quantity_UI_Milestone_State.ts | 18 +--- .../test_244_13_b08_milestone_place_leg.py | 6 +- .../test_244_14_b08_milestone_item_input.py | 2 +- resources/tester/test_244_1_b08_milestone.py | 9 +- .../test_244_2_b08_milestone_diagram.py | 3 +- .../tester/test_244_4_b08_milestone_modal.py | 11 ++- .../test_244_6_b08_milestone_pan_client.py | 6 +- .../tester/test_244_7_b08_milestone_kakao.py | 10 +- .../test_244_8_b08_milestone_no_scroll.py | 8 +- .../test_250_1_b08_milestone_roads_width.py | 72 ++++++++++++++ 17 files changed, 305 insertions(+), 186 deletions(-) create mode 100644 resources/tester/test_250_1_b08_milestone_roads_width.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index f77d85f6a..3030ee999 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -8,11 +8,10 @@ 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). -구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 -pick = 참(화면 「도로 구분 고르기」). -도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 · -그 밖 = 포장 · 비포장 · -포장 = 2차 + 1차 + 포장. +구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7). +도로 구분(152) = 포장 종류는 선 자리로 정함(`road_of`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = +1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · +포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). """ from __future__ import annotations @@ -22,6 +21,13 @@ from typing import Any ROADS = ("2차포장", "1차포장", "포장", "비포장") KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} +# 구간 자리 → 포장 종류(152) — via = 거래처 → 기준점 · base = 기준점 → 시점 · direct = 거래처 → 시점 · mid = 시점 → 중점 +PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"} + + +def road_of(road: str | None, kind: str) -> str: + """그 구간 도로 구분 — 비포장만 사용자 값 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류.""" + return "비포장" if road == "비포장" else PAVED[kind] def r3(value: float) -> float: @@ -35,16 +41,10 @@ def leg_km(leg: dict[str, Any]) -> float | None: return leg.get("auto_km") if km is None else km -def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> bool: - """도로 구분마다 더함 — 안 고른 구간이 있으면 참.""" - pick = False +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None, kind: str) -> None: + """도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음).""" for leg in legs or []: - key = KEYS.get(leg.get("road")) - if key: - sums[key] += float(leg_km(leg) or 0) - else: - pick = True - return pick + sums[KEYS[road_of(leg.get("road"), kind)]] += float(leg_km(leg) or 0) def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: @@ -64,11 +64,12 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} - pick = add_legs(sums, route.get("legs")) - if base.get("enabled") and route.get("via_base", True): - pick = add_legs(sums, base.get("legs")) or pick + via = bool(base.get("enabled")) and route.get("via_base", True) is not False + add_legs(sums, route.get("legs"), "via" if via else "direct") + if via: + add_legs(sums, base.get("legs"), "base") if row.get("dest") == "mid": - add_legs(sums, [mid]) + add_legs(sums, [mid], "mid") paved = sums["s2"] + sums["s1"] + sums["pv"] out.append( { @@ -85,7 +86,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "unpaved": r3(sums["un"]), "total": r3(paved + sums["un"]), "note": row.get("note") or "", - "pick": pick, + "pick": False, } ) return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 50aa3ae78..7fe174baf 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -9,17 +9,48 @@ * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) - * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 - * pick = 참(화면 「도로 구분 고르기」) - * 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · - * 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장 + * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) + * 도로 구분(152) = 포장 종류는 선 자리로 정함(`roadOf`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = + * 1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · + * 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; -/** 구간 도로 구분 고르기 — 기준점 거침 = 2차 · 1차 · 비포장 · 아니면 포장 · 비포장(244-13) */ -export const roadsFor = (viaBase: boolean): Road[] => - viaBase ? ["2차포장", "1차포장", "비포장"] : ["포장", "비포장"]; +/** 구간 자리(152) — 거래처 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · 시점 → 중점(mid) */ +export type SegKind = "via" | "base" | "direct" | "mid"; +/** 포장 종류 = 선 자리로 정함(152) — 기준점이 생기면 거래처 → 기준점 = 2차 · 기준점 → 시점 = 1차 · 없으면 포장 */ +export const PAVED: Record = { + via: "2차포장", + base: "1차포장", + direct: "포장", + mid: "포장", +}; +/** 그 구간 도로 구분 — 사용자는 비포장만 고름 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류 */ +export const roadOf = (road: Road | null | undefined, kind: SegKind): Road => + road === "비포장" ? "비포장" : PAVED[kind]; +/** 도로 구분을 선 자리 포장 종류로 바꾼 사본(152) — 다이어그램 선 글 · 그림용(저장 값은 그대로) */ +export function withRoads(data: MilestoneData): MilestoneData { + const fix = (legs: Leg[], kind: SegKind): Leg[] => + legs.map((leg) => ({ ...leg, road: roadOf(leg.road, kind) })); + return { + ...data, + site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, + base: { ...data.base, legs: fix(data.base.legs, "base") }, + routes: data.routes.map((r) => ({ + ...r, + legs: fix(r.legs, data.base.enabled && r.via_base !== false ? "via" : "direct"), + })), + }; +} + +/** 선 자리 — from = 거래처 id · "base" · "start"(시점 → 중점) */ +export function segKind(data: MilestoneData, from: string): SegKind { + if (from === "start") return "mid"; + if (from === "base") return "base"; + const route = data.routes.find((r) => r.id === from); + return data.base.enabled && route?.via_base !== false ? "via" : "direct"; +} /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; export type Dir = (typeof DIRS)[number]; @@ -97,15 +128,9 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10 /** 구간 km — 사용자 값 우선 · 없으면 자동 값 */ export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; -/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ -function addLegs(sums: Record, legs: Leg[] | undefined): boolean { - let pick = false; - for (const leg of legs ?? []) { - const key = leg.road ? KEYS[leg.road] : undefined; - if (key) sums[key] += Number(legKm(leg) ?? 0); - else pick = true; - } - return pick; +/** 도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음) */ +function addLegs(sums: Record, legs: Leg[] | undefined, kind: SegKind): void { + for (const leg of legs ?? []) sums[KEYS[roadOf(leg.road, kind)]] += Number(legKm(leg) ?? 0); } /** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ @@ -125,9 +150,10 @@ export function computeMilestone( for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; - let pick = addLegs(sums, route.legs); - if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick; - if (row.dest === "mid") addLegs(sums, [mid]); + const via = !!base?.enabled && route.via_base !== false; + addLegs(sums, route.legs, via ? "via" : "direct"); + if (via) addLegs(sums, base.legs, "base"); + if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; out.push({ route: route.id, @@ -143,7 +169,7 @@ export function computeMilestone( unpaved: r3(sums.un), total: r3(paved + sums.un), note: row.note ?? "", - pick, + pick: false, }); }); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 90ff5b010..e48adb616 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -4,13 +4,17 @@ display: flex; flex-direction: column; gap: var(--spacing-8); - /* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */ - width: fit-content; - max-width: 100%; + /* 149 — 페이지 폭 전체(다이어그램 · 표 같은 폭) */ + width: 100%; } -.b08-milestone > .b08-milestone__diagram { - contain: inline-size; +/* 149 표도 폭 전체 — 열 너비는 비율대로 늘음 */ +.b08-milestone > .ui-data-table { + width: 100%; +} + +.b08-milestone > .ui-data-table .ui-data-table__table { + width: 100%; } .b08-milestone__route { @@ -53,6 +57,8 @@ /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ .b08-milestone__diagram { + /* 149 높이 고정 · 그림은 틀 비율로 넓힌 범위에 맞춤(스크롤 없음 · 244-8) */ + height: clamp(420px, 58vh, 760px); overflow: hidden; border: 1px solid var(--color-border); border-radius: 6px; @@ -61,7 +67,6 @@ .b08-milestone__diagram svg { display: block; - margin: 0 auto; font-size: 13px; } @@ -166,15 +171,10 @@ font-size: 10px; } -/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ -.b08-milestone .is-pick-road { - color: var(--color-danger); -} - -/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */ +/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */ .b08-milestone__map { width: 100%; - height: 360px; + height: 480px; border: 1px solid var(--color-border); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index df187f338..05058419a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -84,7 +84,6 @@ export function addRoute(projectId: string, init: Partial = {}): function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; - let pickRows: boolean[] = []; const makeTable = (columns: DataColumn[]) => { const t = createDataTable({ columns, @@ -92,7 +91,6 @@ function tableView(projectId: string): MilestoneView { caption: "거리이정표", empty: "", editOnDouble: true, - cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), editable: (_i, key) => key === "item" || key === "company" || key === "note", onEdit: (i, key, text) => { const at = keys[i]; @@ -143,16 +141,8 @@ function tableView(projectId: string): MilestoneView { table = fresh; } keys = rows.map(({ route, index }) => ({ route, index })); - pickRows = rows.map((r) => r.pick); none.hidden = rows.length > 0; - // 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7) - table.setRows( - rows.map((r) => ({ - ...r, - dest: `${DEST_TEXT[r.dest]} ▾`, - total: r.pick ? "도로 구분 고르기" : r.total, - })), - ); + table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 91cb5e2d6..abc0811a4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -7,7 +7,7 @@ * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰) - * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달 + * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달 * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) @@ -17,9 +17,15 @@ import { createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; -import { midKm, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc"; import { - chainVia, + midKm, + roadOf, + segKind, + withRoads, + type MilestoneRoute, + type Road, +} from "./B08_Quantity_Milestone_Calc"; +import { layoutMilestone, legAt, panContent, @@ -62,12 +68,13 @@ function editLegAt( const leg = legAt(data, from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); + const kind = segKind(data, from); const road = createSelectField({ label: "도로 구분", - options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름 + options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름 compact: true, }); - road.select.value = leg.road ?? ""; + road.select.value = roadOf(leg.road, kind); const km = el("input", { className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, @@ -109,6 +116,27 @@ function editLegAt( km.select(); } +/** 처음 배율(149) — 상자 범위가 틀에 차도록 당김(1 ~ 1.8배 · 가운데 맞춤) · 좌표 = viewBox(바탕 이동 vx · vy 뺌) */ +function fitBoxes( + boxes: DiagramBox[], + vx: number, + vy: number, + vw: number, + vh: number, +): ZoomPanState | undefined { + if (!boxes.length) return undefined; + const pad = 36; + const x0 = Math.min(...boxes.map((b) => b.x)) - pad - vx; + const y0 = Math.min(...boxes.map((b) => b.y)) - pad - vy; + const x1 = Math.max(...boxes.map((b) => b.x + b.w)) + pad - vx; + const y1 = Math.max(...boxes.map((b) => b.y + b.h)) + pad - vy; + const scale = Math.min(1.8, Math.max(1, Math.min(vw / (x1 - x0), vh / (y1 - y0)))); + if (scale <= 1.001) return undefined; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + return { scale, tx: vw / 2 - cx * scale, ty: vh / 2 - cy * scale }; +} + /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, @@ -118,7 +146,12 @@ export function createDiagram( host.dataset.b08Milestone = "diagram"; // 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음 host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); - let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 + let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로(사용자가 줌 · 팬 했을 때만) + let userZoomed = false; + // 사용자 줌 · 팬 = 그 배율 지킴 · 그 전엔 그릴 때마다 상자 범위에 맞춰 당김(149 · 글씨 읽힘 · 새 상자도 보임) + host.addEventListener("wheel", () => (userZoomed = true)); + host.addEventListener("pointermove", (e) => e.buttons && (userZoomed = true)); + host.addEventListener("dblclick", () => (userZoomed = false)); // 공용 더블클릭 = 원배율 const INFO: Record void> = { route: (id) => openPlaceModal(projectId, { add: false, id }), site: () => openSiteInfo(projectId), @@ -153,7 +186,7 @@ export function createDiagram( // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림) const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null); const layout = layoutMilestone( - data, + withRoads(data), // 선 글 도로 구분 = 선 자리 포장 종류(152) { start_lonlat: addr?.start_lonlat ?? site?.start_lonlat, mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat, @@ -162,19 +195,24 @@ export function createDiagram( }, mk, ); - const [vx, vy, vw, vh] = layout.view; + // 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠 + // (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음) + const fw = Math.max(host.clientWidth, 320); + const fh = Math.max(host.clientHeight, 240); + const [lx, ly, lw, lh] = layout.view; + const scale = Math.max(lw / fw, lh / fh); + const vw = fw * scale; + const vh = fh * scale; + const vx = lx - (vw - lw) / 2; + const vy = ly - (vh - lh) / 2; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { viewBox: `0 0 ${vw} ${vh}`, - width: vw, - height: vh, + width: fw, + height: fh, role: "img", "aria-label": "거리이정표 다이어그램", }); - // 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게) - // 높이 48vh 넘으면 그만큼 좁혀 틀 안에 다 들어감(244-8 · 스크롤 없음) - root.style.width = `min(100%, calc(48vh * ${vw / vh}))`; - root.style.height = "auto"; const layer = svg("g", {}); const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); layer.append(shift); @@ -268,7 +306,7 @@ export function createDiagram( widthPx: vw, heightPx: vh, pad: { left: 0, right: 0, top: 0, bottom: 0 }, - initial: zoomState, + initial: userZoomed ? zoomState : fitBoxes(layout.boxes, vx, vy, vw, vh), onChange: (state) => (zoomState = state), wheelZoom: true, content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) @@ -276,5 +314,14 @@ export function createDiagram( }); }; refresh(); + // 틀 크기가 바뀌면(창 · 패널 접기) 그 비율로 다시 그림 + let lastW = 0; + new ResizeObserver(() => { + const w = host.clientWidth; + if (w && Math.abs(w - lastW) > 1) { + lastW = w; + refresh(); + } + }).observe(host); return { root: host, refresh }; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index f8f7cd3e6..98d50bffa 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -3,8 +3,8 @@ * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. * * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) - * 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 · - * 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`) + * 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · + * 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) @@ -13,7 +13,14 @@ import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc"; +import { + ROADS, + roadOf, + segKind, + type Leg, + type Road, + type SegKind, +} from "./B08_Quantity_Milestone_Calc"; import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; @@ -35,18 +42,18 @@ const km = (text: string): number | null => { /** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */ export function legsEditor( initial: Leg[], - via: boolean, + kind: SegKind, ): { root: HTMLElement; value: () => Leg[] } { const list = el("div"); const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; const add = (leg: Leg): void => { const road = createSelectField({ label: "도로 구분", - options: roadOptions(via, true, leg.road), + options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름 compact: true, }); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); - road.select.value = leg.road ?? ""; + road.select.value = roadOf(leg.road, kind); dist.input.value = leg.km == null ? "" : String(leg.km); const auto = leg.auto_km ?? null; if (auto != null) { @@ -102,17 +109,17 @@ function openMidLeg(projectId: string): void { mount: (body, close, actions) => { const road = createSelectField({ label: "도로 구분", - options: roadOptions(false, false, data.site.mid_road), + options: roadOptions("mid"), compact: true, }); - road.select.value = data.site.mid_road; + road.select.value = roadOf(data.site.mid_road, "mid"); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); dist.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 dist.input.dataset.b08MilestoneLegKm = "start"; const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); @@ -152,7 +159,8 @@ export function openLegInfo(projectId: string, from: string): void { return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; }; const via = chainVia(liveMilestone(projectId), from); - let legs = legsEditor(legsOf(), via); + const kind = segKind(liveMilestone(projectId), from); + let legs = legsEditor(legsOf(), kind); const status = hint(""); status.dataset.b08MilestoneLegStatus = ""; /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ @@ -164,7 +172,7 @@ export function openLegInfo(projectId: string, from: string): void { putMilestone(projectId, fresh); }; const redrawLegs = (): void => { - const next = legsEditor(legsOf(), via); + const next = legsEditor(legsOf(), kind); legs.root.replaceWith(next.root); legs = next; }; @@ -223,7 +231,7 @@ export function openLegInfo(projectId: string, from: string): void { }); find.dataset.b08MilestoneLegFind = ""; const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { apply(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index f0454ab76..5a8f3e891 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -4,9 +4,9 @@ * * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · - * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] - * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 - * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 + * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] + * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 + * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 조작 = 캐시 · [저장] 때 서버 @@ -24,7 +24,13 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; -import type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc"; +import { + roadOf, + type Dest, + type MilestoneRoute, + type MilestoneRow, + type Road, +} from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -69,7 +75,8 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { const setKind = (k: "route" | "base"): void => { list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } }))); item.input.placeholder = `예 ${ITEMS[k][0]}`; - dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정 + // 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149) + item.root.style.visibility = dest.root.style.visibility = k === "base" ? "hidden" : ""; }; setKind(kind); dest.select.value = r.dest; @@ -114,43 +121,15 @@ export function openPlaceModal( const baseOption = sort.select.querySelector('option[value="base"]'); if (target.add && data.base.enabled && baseOption) { baseOption.disabled = true; - baseOption.textContent = "기준위치(있음 — 기준점 상자에서 고침)"; + baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)"; } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); name.input.value = editBase ? data.base.name : (route?.name ?? ""); name.input.dataset.b08MilestoneAdd = "company"; const nameLabel = name.root.querySelector(".ui-field__label"); - const initRows: MilestoneRow[] = route?.rows.length - ? route.rows - : [{ item: "", dest: "start", note: "" }]; - const rows = initRows.map((r) => rowField(r, kind)); - const [first] = rows; - const more = el("div"); - const addRow = (field: ReturnType): void => { - const drop = createButton({ - label: "×", - size: "sm", - variant: "ghost", - onClick: () => { - rows.splice(rows.indexOf(field), 1); - row.remove(); - }, - }); - const row = line(field.item.root, field.dest.root, drop); - more.append(row); - }; - rows.slice(1).forEach(addRow); - const plusRow = createButton({ - label: "+ 줄", - size: "sm", - variant: "ghost", - onClick: () => { - const field = rowField({ item: "", dest: "start", note: "" }, kind); - rows.push(field); - addRow(field); - }, - }); + // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 + const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); const via = createCheckbox("기준점 거침"); via.input.checked = route?.via_base ?? true; via.root.hidden = !route || !data.base.enabled; @@ -163,22 +142,23 @@ export function openPlaceModal( const setKind = (k: "route" | "base"): void => { kind = k; if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름"; - rows.forEach((r) => r.setKind(k)); - more.hidden = plusRow.hidden = k === "base"; + first.setKind(k); }; sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base")); setKind(kind); - const rowValues = (): MilestoneRow[] => - rows.map((r) => ({ - item: r.item.input.value.trim(), - dest: r.dest.select.value as Dest, - note: r.note, - })); + const rowValues = (): MilestoneRow[] => [ + { + item: first.item.input.value.trim(), + dest: first.dest.select.value as Dest, + note: first.note, + }, + ...(route?.rows.slice(1) ?? []), + ]; const moved = (p: { lon: number | null; lat: number | null }): boolean => p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat); const ok = createButton({ - label: target.add ? "등록" : "고침", + label: target.add ? "등록" : "수정", variant: "filled", onClick: () => { const text = name.input.value.trim(); @@ -202,16 +182,14 @@ export function openPlaceModal( if (moved(where) || r.via_base !== route.via_base) segment = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); - const same = fresh.routes.find((r) => r.name.trim() === text); - if (same) { - same.rows.push(...rowValues()); - if (where.lon != null) Object.assign(same, where); - putMilestone(projectId, fresh); - } const viaBase = target.add && target.viaBase; - const id = - same?.id ?? - addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where }); + const id = addRoute?.({ + name: text, + via_base: viaBase, + dir: null, + rows: rowValues(), + ...where, + }); if (id && where.lon != null) segment = id; } close(); @@ -241,8 +219,6 @@ export function openPlaceModal( className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), - more, - plusRow, via.root, head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, @@ -283,12 +259,12 @@ export function openSiteInfo(projectId: string): void { mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", - options: roadOptions(false, false, data.site.mid_road), + options: roadOptions("mid"), compact: true, }); - road.select.value = data.site.mid_road; + road.select.value = roadOf(data.site.mid_road, "mid"); const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 301afa94e..b14239be1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -19,7 +19,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { roadsFor, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import { PAVED, type MilestoneData, type SegKind } from "./B08_Quantity_Milestone_Calc"; import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; @@ -219,19 +219,9 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } -/** 도로 구분 ▾ — via = 기준점 거쳐 시점 들어가는 구간(2차 · 1차 · 비포장) · 아니면 포장 · 비포장(244-13) · - * blank = 「도로 구분 고르기」 빈 값(자동 판정 없음 · 244-7) · 목록 밖 옛 값도 보임 */ -export function roadOptions( - via: boolean, - blank: boolean, - current?: string | null, -): Array<{ value: string; text: string }> { - const roads: string[] = roadsFor(via); - if (current && !roads.includes(current)) roads.push(current); - return [ - ...(blank ? [{ value: "", text: "도로 구분 고르기" }] : []), - ...roads.map((r) => ({ value: r, text: r })), - ]; +/** 도로 구분 ▾(152) — 그 자리 포장 종류 · 비포장 둘(포장 종류는 선 자리로 정함 · `roadOf`) */ +export function roadOptions(kind: SegKind): Array<{ value: string; text: string }> { + return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } /** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index e409a1606..5234c4374 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -95,13 +95,15 @@ def test_도로_구분_갈래_연결선_구간_끝(): got = _node( [CALC, LAYOUT], f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; -console.log(JSON.stringify([roadsFor(true), roadsFor(false), +console.log(JSON.stringify([["g", "d", "base", "start"].map((f) => PAVED[segKind(D, f)]), + ["g", "base"].map((f) => segKind(O, f)), ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", ) - assert got[0] == ["2차포장", "1차포장", "비포장"] and got[1] == ["포장", "비포장"] + # 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장 + assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "base"] assert got[2] == [True, False, True, False] and got[3] == [False, False] assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], "to": [127.85, 35.66]} # fmt: skip diff --git a/resources/tester/test_244_14_b08_milestone_item_input.py b/resources/tester/test_244_14_b08_milestone_item_input.py index f13a81e1a..b4fe3871b 100644 --- a/resources/tester/test_244_14_b08_milestone_item_input.py +++ b/resources/tester/test_244_14_b08_milestone_item_input.py @@ -78,6 +78,6 @@ def test_항목_손_입력_제안_분류_바꿔도_글_유지(): def test_저장_값_손_글_그대로(): modal = MODAL.read_text(encoding="utf-8") - assert "item: r.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) + assert "item: first.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) assert 'createSelectField({ label: "항목"' not in modal assert len(modal.splitlines()) <= 700 diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index ef1e7b574..d7013901c 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -64,13 +64,16 @@ def test_견본_표_합(): assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8) assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2) assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청" - # 기준점 안 거침 · 목적지 중점 = 시점 → 중점(기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 - assert (c["s2"], c["s1"], c["un"], c["total"]) == (61.2, 2.05, 1.5, 64.75) + # 기준점 안 거침 = 거래처 → 시점 = 포장(152 · 옛 2차 · 1차 값도 자리로) · 목적지 중점 = 시점 → 중점 + # (기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 + assert (c["s2"], c["s1"], c["pv"], c["un"], c["total"]) == (0, 0, 63.25, 1.5, 64.75) # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False}, "site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip a2, _, c2 = compute_milestone(no_base, 1500.0) - assert a2["total"] == 23.8 and (c2["s1"], c2["un"]) == (2.47, 0) + # 기준점 없음 = 2차 · 1차 없이 포장(152) · 시점 → 중점 1차포장 값 = 포장 + assert a2["total"] == 23.8 and (a2["s2"], a2["pv"]) == (0, 23.8) + assert (c2["s1"], c2["pv"], c2["un"]) == (0, 63.67, 0) def test_거울_TS(): diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index a8dc4adb0..d07f91343 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -150,7 +150,8 @@ def test_화면_상자_누름_더하기_창(): assert "openPlaceModal(projectId, { add: true, viaBase }, addRoute)" in ui modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8") assert "openModal({" in modal - assert "same.rows.push(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄 + # 149 상자 하나 = 항목 하나 — [+ 줄] · 같은 이름 합침 없음 + assert "same.rows.push" not in modal and '"+ 줄"' not in modal page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 8d533d060..5f8db74f8 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -158,16 +158,17 @@ def test_화면_모달_팬줌_그림탭(): assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 - for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): + for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): assert text in add leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") - assert "legsEditor(legsOf(), via)" in leg + assert "legsEditor(legsOf(), kind)" in leg diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert "route: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert "start: () => openSiteInfo(projectId)," in diagram # ③ 팬 · 줌 = 공용 assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram - assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram + # 149 처음 배율 = 상자 범위 맞춤(사용자 줌 · 팬 뒤엔 그 배율) + assert "wheelZoom: true" in diagram and "initial: userZoomed ? zoomState : fitBoxes(" in diagram # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 244-11 에서 서버 자동으로) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert ( @@ -206,9 +207,9 @@ def test_선_글_바로_고침_모달과_한_벌(): assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} - # 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸 + # 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도) (row,) = compute_milestone(after, 1500.0) - assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 0, 23.8) + assert (row["s2"], row["s1"], row["un"], row["total"]) == (23.8, 0, 0, 23.8) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'span.addEventListener("dblclick"' in ui and "e.stopPropagation();" in ui assert "editLegAt(projectId, span, ln.from, i, mk)" in ui diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 9b5248ef8..9e609a8ef 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -104,10 +104,10 @@ def test_거래처_상자_항목_글(): def test_표_폭_다이어그램_폭_좌측_카드_없음(): css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") block = css[css.index(".b08-milestone {") :] - assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비 - assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css + assert "width: 100%;" in block[: block.index("}")] # 149 틀 = 페이지 폭 전체 + assert ".b08-milestone > .ui-data-table {\n width: 100%;" in css # 149 표도 폭 전체 ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui # 다이어그램 = 틀 너비 + assert "const fw = Math.max(host.clientWidth, 320);" in ui # 149 다이어그램 = 틀 폭 · 틀 비율 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert "children: [diagram.root, none, table.root]" in page # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 0392f8230..30f715237 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -197,11 +197,11 @@ def test_사용자_값_우선_고르기_표시(): a, b = compute_milestone(data, 1500.0) # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) - # 도로 구분 안 고름 = 합에 안 듦 · pick - assert (b["total"], b["pick"]) == (0, True) - data["base"]["legs"][0]["road"] = None + # 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0 + assert (b["total"], b["pick"]) == (0, False) + data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) a2, _ = compute_milestone(data, 1500.0) - assert (a2["s1"], a2["total"], a2["pick"]) == (0, 12.0, True) + assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False) # TS 짝 거울 for d in (data, _data()): ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts", @@ -223,7 +223,7 @@ def test_화면_길찾기_단추_키_화면에_없음(): leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal assert "/quantity/milestone/directions" in state and "segmentEnds(" in state - assert '"도로 구분 고르기"' in table and "r.pick" in table + assert '"도로 구분 고르기"' not in table and "r.pick" not in table # 152 자리로 정함 assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py index cbf8855aa..17c980d2a 100644 --- a/resources/tester/test_244_8_b08_milestone_no_scroll.py +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -37,9 +37,11 @@ def test_틀_스크롤_없음_그림_맞춤(): assert "overflow: hidden;" in block and "overflow: auto" not in block assert "max-height" not in block # 높이는 그림이 맞춤 — 틀이 자르지 않음 ui = _read(DIAGRAM) - # 너비 = 틀 · 높이 48vh 넘으면 비율대로 좁힘(height auto 라 화면 px ↔ 그림 좌표 그대로) - assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui - assert 'root.style.height = "auto";' in ui + # 149 틀 = 폭 전체 · 높이 고정(CSS) · 그림 범위를 틀 비율로 넓힘(화면 px ↔ 그림 좌표 가로세로 같음) + assert "const scale = Math.max(lw / fw, lh / fh);" in ui + assert "height: clamp(420px, 58vh, 760px);" in _read( + "B08_Quantity/B08_Quantity_UI_Milestone.css" + ) def test_휠_진짜_사건_줌_스크롤_막음(): diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py new file mode 100644 index 000000000..fa9bfb2dc --- /dev/null +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -0,0 +1,72 @@ +"""PLAN 250(사용자 149 · 150 · 152) — 거리이정표 도로 구분 = 선 자리 · 모달 정리 · 폭 · 지도 높이.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, road_of # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _ts(expr: str): + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")) + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", + "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_도로_구분_선_자리_짝(): + cases = [ + (r, k) + for r in (None, "2차포장", "1차포장", "포장", "비포장") + for k in ("via", "base", "direct", "mid") + ] + py = [road_of(r, k) for r, k in cases] + assert py == _ts(f"{json.dumps(cases)}.map(([r, k]) => roadOf(r, k))") + assert road_of(None, "via") == "2차포장" and road_of("포장", "base") == "1차포장" + assert road_of("2차포장", "direct") == "포장" and road_of("비포장", "via") == "비포장" + + +def test_기준점_생기면_2차_1차_없으면_포장(): + leg = lambda road, km: {"road": road, "km": km} # noqa: E731 + route = {"id": "r", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [leg(None, 23.8)], "rows": [{"item": "장비", "dest": "mid", "note": ""}]} # fmt: skip + data = {"site": {"mid_station_m": 800, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, + "legs": [leg(None, 0.6)]}, + "routes": [route]} # fmt: skip + (a,) = compute_milestone(data, None) + assert (a["s2"], a["s1"], a["pv"], a["un"], a["total"]) == (23.8, 0.6, 0, 0.8, 25.2) + data["base"]["enabled"] = False + (b,) = compute_milestone(data, None) + assert (b["s2"], b["s1"], b["pv"], b["un"], b["total"]) == (0, 0, 23.8, 0.8, 24.6) + + +def test_모달_상자_하나_항목_하나_수정_글(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert '"+ 줄"' not in modal and "same.rows" not in modal + assert 'label: "고침"' not in modal + leg and modal.count('"수정"') >= 2 + assert 'item.root.style.visibility = dest.root.style.visibility = k === "base"' in modal + + +def test_폭_전체_지도_높이_한_값(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index(".b08-milestone {") :] + assert "width: 100%;" in block[: block.index("}")] + assert ".b08-milestone > .ui-data-table .ui-data-table__table {\n width: 100%;" in css + assert css.count("height: 480px;") == 1 and ".b08-milestone__map {" in css From 5a249afb2111e40e0a38de6ddb40abe964a9a18f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:13:15 +0900 Subject: [PATCH 009/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=E2=80=94=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=A7=80=EB=8F=84=20=EB=B3=B4=EA=B8=B0=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EC=83=81=EC=9E=90=20=EC=83=89=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EB=81=8C=EC=96=B4=20=EC=98=AE=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EC=9E=90=EB=8F=99=20=EC=A0=95=EB=A0=AC=20(250-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오 지도 처음 한 번만 맞춤 · 고르기 · 끌기 뒤 중심 · 줌 그대로 - 공사지 강조색 · 기준점 경고색 · 거래처 성공색(테마 변수 · 밝음 · 어두움) - 거래처 · 기준점 상자 끌어 옮김(pos 저장) · [자동 정렬] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 3 + B08_Quantity/B08_Quantity_Milestone_Layout.ts | 7 ++- B08_Quantity/B08_Quantity_Router_Milestone.py | 2 + B08_Quantity/B08_Quantity_UI_Milestone.css | 24 +++++++- .../B08_Quantity_UI_Milestone_Diagram.ts | 61 ++++++++++++++++++- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 12 +++- .../test_250_1_b08_milestone_roads_width.py | 34 +++++++++++ 7 files changed, 137 insertions(+), 6 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 7fe174baf..e518c7ee3 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -81,6 +81,8 @@ export interface MilestoneRoute { dir: Dir | null; lon?: number | null; lat?: number | null; + /** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */ + pos?: [number, number] | null; /** 업체 → 기준점(거침) · 시점 구간 */ legs: Leg[]; rows: MilestoneRow[]; @@ -95,6 +97,7 @@ export interface MilestoneData { dir: Dir | null; lon?: number | null; lat?: number | null; + pos?: [number, number] | null; /** 기준점 → 시점 공통 구간 */ legs: Leg[]; }; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 12f3c648b..fcc802a0c 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -362,11 +362,16 @@ export function layoutMilestone( return [b.x + b.w / 2, b.y + b.h / 2]; }; /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ + // 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리 + const posOf = (id: string): [number, number] | null | undefined => + id === "base" ? data.base.pos : data.routes.find((r) => r.id === id)?.pos; const put = (box: DiagramBox, out?: [number, number]): void => { const inner = box.w - 2 * INSET; box.label = clip(box.label, inner, FONT.label); if (box.items) box.items = clip(box.items, inner, FONT.items); - for (let i = 0; out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { + const pos = posOf(box.id); + if (pos) [box.x, box.y] = pos; + for (let i = 0; !pos && out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { box.x += out[0] * 4; box.y += out[1] * 4; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 01f281924..e4f3810de 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -92,6 +92,8 @@ class Place(BaseModel): dir: Dir | None = None lon: float | None = Field(default=None, ge=-180, le=180) lat: float | None = Field(default=None, ge=-90, le=90) + # 153 다이어그램에 끌어 놓은 자리(왼 위 · 그림 좌표) — 없으면 방위 자동 자리 + pos: tuple[float, float] | None = None legs: list[Leg] = Field(default_factory=list, max_length=20) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index e48adb616..ac5998531 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -56,7 +56,15 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ +/* 153 [자동 정렬] — 다이어그램 왼쪽 위(끌어 놓은 자리가 있을 때만 · 오른쪽 위 = 진행단계 판 자리) */ +.b08-milestone__arrange { + position: absolute; + top: 8px; + left: 8px; +} + .b08-milestone__diagram { + position: relative; /* 149 높이 고정 · 그림은 틀 비율로 넓힌 범위에 맞춤(스크롤 없음 · 244-8) */ height: clamp(420px, 58vh, 760px); overflow: hidden; @@ -86,17 +94,29 @@ fill: var(--color-text); } +/* 153 상자 색 — 공사지(시점 · 중점) = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 옅은 채움 + 테두리(테마 변수 · 밝음 · 어두움 같이) */ .b08-milestone__box.is-site rect { - fill: var(--color-surface-raised); + fill: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface)); + stroke: var(--color-accent); stroke-dasharray: 4 3; } -.b08-milestone__box.is-base rect, .b08-milestone__box.is-start rect, .b08-milestone__box.is-mid rect { + fill: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)); stroke: var(--color-accent); } +.b08-milestone__box.is-base rect { + fill: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + stroke: var(--color-warning); +} + +.b08-milestone__box.is-route rect { + fill: color-mix(in srgb, var(--color-success) 14%, var(--color-surface)); + stroke: var(--color-success); +} + .b08-milestone__box.is-route { cursor: pointer; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index abc0811a4..e0370ed0e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -15,7 +15,7 @@ * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, @@ -179,6 +179,61 @@ export function createDiagram( return g; }; + /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) + * 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */ + const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => { + g.addEventListener("pointerdown", (down) => { + if (down.button !== 0) return; + down.stopPropagation(); // 빈 곳 팬 안 함 + const ctm = shift.getScreenCTM(); + const k = ctm ? ctm.a : 1; // 화면 px ÷ 그림 좌표 + let moved = false; + const move = (e: PointerEvent): void => { + const dx = (e.clientX - down.clientX) / k; + const dy = (e.clientY - down.clientY) / k; + if (!moved && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 4) return; + moved = true; + g.setAttribute("transform", `translate(${dx} ${dy})`); + }; + const up = (e: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + if (!moved) return; + const swallow = (c: Event): void => c.stopPropagation(); // 끌고 난 누름 = 모달 아님 + g.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => g.removeEventListener("click", swallow, { capture: true }), 0); + const pos: [number, number] = [ + Math.round(box.x + (e.clientX - down.clientX) / k), + Math.round(box.y + (e.clientY - down.clientY) / k), + ]; + const fresh = liveMilestone(projectId); + const target = box.id === "base" ? fresh.base : fresh.routes.find((r) => r.id === box.id); + if (!target) return; + target.pos = pos; + userZoomed = true; // 옮긴 뒤 배율 그대로 + putMilestone(projectId, fresh); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + }; + + // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 + const arrange = createButton({ + label: "자동 정렬", + size: "sm", + variant: "ghost", + onClick: () => { + const fresh = liveMilestone(projectId); + fresh.base.pos = null; + fresh.routes.forEach((r) => (r.pos = null)); + userZoomed = false; + putMilestone(projectId, fresh); + }, + }); + arrange.classList.add("b08-milestone__arrange"); + arrange.dataset.b08MilestoneArrange = ""; + const refresh = (): void => { const data = liveMilestone(projectId); const site = siteInfo(); @@ -262,6 +317,7 @@ export function createDiagram( info(); }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); + if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); shift.append(g); @@ -299,7 +355,8 @@ export function createDiagram( }); shift.append(text); } - host.replaceChildren(root); + arrange.hidden = !data.base.pos && !data.routes.some((r) => r.pos); + host.replaceChildren(root, arrange); attachSvgZoomPan({ svg: root, layer, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index af216b5cb..e30eb7972 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -30,6 +30,8 @@ interface KMap { setCenter(p: LatLng): void; setLevel(level: number): void; relayout(): void; + getBounds(): { contain(p: LatLng): boolean }; + panTo(p: LatLng): void; } interface KOverlay { setMap(map: KMap | null): void; @@ -202,6 +204,7 @@ export function editMap( hint.dataset.b08MilestoneMapHint = ""; let map: KMap | null = null; let drawn: KOverlay[] = []; + let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로 const set = (pins: MapPin[], lines: Array>): void => { loadKakaoMaps(projectId).then( @@ -253,13 +256,20 @@ export function editMap( }), ]; drawn.forEach((o) => o.setMap(map)); - if (points.length > 1) { + if (fitted) { + // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) + const view = map.getBounds(); + const out = pins.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); + if (out) map.panTo(new maps.LatLng(out.lat, out.lon)); + } else if (points.length > 1) { const bounds = new maps.LatLngBounds(); points.forEach((p) => bounds.extend(p)); map.setBounds(bounds); + fitted = true; } else { map.setCenter(first); map.setLevel(points.length ? 4 : 9); + fitted = points.length > 0; } }), (error: unknown) => { diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index fa9bfb2dc..9a0151aca 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -70,3 +70,37 @@ def test_폭_전체_지도_높이_한_값(): assert "width: 100%;" in block[: block.index("}")] assert ".b08-milestone > .ui-data-table .ui-data-table__table {\n width: 100%;" in css assert css.count("height: 480px;") == 1 and ".b08-milestone__map {" in css + + +def test_153_지도_보기_유지_상자_색_끌어_옮김(): + kmap = _read("B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts") + assert ( + "let fitted = false;" in kmap and "map.panTo(" in kmap + ) # 처음 한 번만 맞춤 · 그 뒤 줌 그대로 + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + for kind, var in ( + ("is-site", "--color-accent"), + ("is-base", "--color-warning"), + ("is-route", "--color-success"), + ): + block = css[css.index(f".b08-milestone__box.{kind} rect {{") :] + assert var in block[: block.index("}")], kind + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui + assert 'label: "자동 정렬"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + + +def test_153_끌어_놓은_자리_레이아웃(): + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) + data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]}, + "routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [], + "rows": [], "pos": [-700, 300]}]} # fmt: skip + expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])" + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", + "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]] From 5c6c8a85b4e9e933fb76726618160ff17ec5bdb8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:13:15 +0900 Subject: [PATCH 010/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20151?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index f023bc187..89bde4ff2 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -180,3 +180,5 @@ 149. 고침이라는 명칭을 수정으로 변경. 컨테이너 항목에서 + 줄이 있는 이유가 뭐야. 거래처 등록은 거래처에 관련 정보를 선에는 경로관련을 묶어서 표현하려고 하는거잖아. 그리고 기준위치에는 항목이 필요없어. 거쳐가야하는 지점이다보니 의미가 없어. 이번 부터는 너가 직접작업해. 거리이정표의 경우에는 폭을 브라우저에 맞춰서 폭 전체를 사용하게 하고 하단의 테이블도 동일하게 폭에 맞춰 열의 사이즈를 조절해줘. 그리고 현재 다이어그램이 줌아웃이 너무 멀어서 글자가 잘안보여 적당히 줌인해줘. 150. 기준점과 선의 컨테이너 내부 카카오맵의 영역의 높이를 더 높여줘. 카카오맵의 높이는 어디서든 같게 해줘. 151. 그리고 선 성분의 연결정보는 컨테이너들을 드래그해서 연결하고 이정보를 가져오는건 어때? (제안 · 물음) +152. 그리고 포장과 비포장이다가 기준점이 생성되면 당연히 1차 포장, 2차 포장이 되어야할것같아. 그리고 그때 선의 연결여부에 따라 1차냐 2차냐가 되는거고 +153. 카카오맵에서 기준점이나 소재지를 지정하면 보기가 초기화되고 줌아웃 상태가 되고 있어. 변화가 없는게 편할것 같아. 공사지, 기준점, 거래처를 기준으로 색상을 조금 넣어주는 좋겠어. 이때 프로그램의 내부에 화이트와 블랙테마 양식이 있으니 참고해. 다음으로 요소들을 드래그로 위치를 변경하거나 정렬하는 기능이 있으면 좋겠어. From 46db8e56c3892aae669b81a1acb9538499a71eea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:14:44 +0900 Subject: [PATCH 011/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20154?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 89bde4ff2..0450eca15 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -182,3 +182,4 @@ 151. 그리고 선 성분의 연결정보는 컨테이너들을 드래그해서 연결하고 이정보를 가져오는건 어때? (제안 · 물음) 152. 그리고 포장과 비포장이다가 기준점이 생성되면 당연히 1차 포장, 2차 포장이 되어야할것같아. 그리고 그때 선의 연결여부에 따라 1차냐 2차냐가 되는거고 153. 카카오맵에서 기준점이나 소재지를 지정하면 보기가 초기화되고 줌아웃 상태가 되고 있어. 변화가 없는게 편할것 같아. 공사지, 기준점, 거래처를 기준으로 색상을 조금 넣어주는 좋겠어. 이때 프로그램의 내부에 화이트와 블랙테마 양식이 있으니 참고해. 다음으로 요소들을 드래그로 위치를 변경하거나 정렬하는 기능이 있으면 좋겠어. +154. (선 연결을 드래그로 — 연결 점 ● 끌어 기준점 · 공사지에 놓기 · 설명 뒤) 동의 작업진행 From e012a09cf27d0fa5d36b553a9648e7fae1016e58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:17:46 +0900 Subject: [PATCH 012/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=84=A0=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=3D=20=EC=97=B0=EA=B2=B0=20=EC=A0=90=20=EB=81=8C=EA=B8=B0=20(25?= =?UTF-8?q?0-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 거래처 상자 ● 를 끌어 기준점 = 기준점 거침 · 공사지 = 시점으로 바로 - 바뀌면 그 구간 km 비움 + 길찾기 한 번 · 등록 모달 「기준점 거침」 체크 없앰 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_UI_Milestone.css | 25 +++++++ .../B08_Quantity_UI_Milestone_Diagram.ts | 67 ++++++++++++++++++- .../B08_Quantity_UI_Milestone_Modal.ts | 9 +-- .../test_250_1_b08_milestone_roads_width.py | 15 +++++ 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index ac5998531..6592fabb7 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -232,3 +232,28 @@ pointer-events: stroke; cursor: pointer; } + +/* 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 · 공사지에 놓으면 연결 */ +.b08-milestone__connect { + fill: var(--color-success); + stroke: var(--color-surface); + stroke-width: 2; + cursor: crosshair; + opacity: 0.55; +} + +.b08-milestone__box:hover .b08-milestone__connect { + opacity: 1; +} + +.b08-milestone__wire { + stroke: var(--color-success); + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +.b08-milestone__box.is-drop-target rect { + stroke-width: 3; + stroke-dasharray: none; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e0370ed0e..0b755e5cb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -34,8 +34,9 @@ import { type DiagramBox, } from "./B08_Quantity_Milestone_Layout"; import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal"; -import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal"; +import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { + findSegmentKm, liveMilestone, putMilestone, roadOptions, @@ -218,6 +219,69 @@ export function createDiagram( }); }; + /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · + * 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + 길찾기 한 번(km · 경로 그림) */ + const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { + const cx = box.x + box.w; + const cy = box.y + box.h / 2; + const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 }); + dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결")); + dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸 + dot.addEventListener("pointerdown", (down) => { + if (down.button !== 0) return; + down.stopPropagation(); // 상자 끌기 · 팬 안 함 + down.preventDefault(); + const toLocal = (e: PointerEvent): [number, number] => { + const m = shift.getScreenCTM(); + if (!m) return [cx, cy]; + const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); + return [p.x, p.y]; + }; + const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy }); + shift.append(wire); + let over: Element | null = null; + const targetOf = (e: PointerEvent): "base" | "site" | null => { + const hit = document.elementFromPoint(e.clientX, e.clientY); + const g = hit?.closest("[data-b08-milestone-box]"); + const id = g?.dataset.b08MilestoneBox; + if (id === "base") return "base"; + return id === "site" || id === "start" || id === "mid" ? "site" : null; + }; + const move = (e: PointerEvent): void => { + const [x, y] = toLocal(e); + wire.setAttribute("x2", String(x)); + wire.setAttribute("y2", String(y)); + const t = targetOf(e); + const g = t ? shift.querySelector(`[data-b08-milestone-box="${t}"]`) : null; + if (g !== over) { + over?.classList.remove("is-drop-target"); + g?.classList.add("is-drop-target"); + over = g; + } + }; + const up = (e: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + wire.remove(); + over?.classList.remove("is-drop-target"); + const t = targetOf(e); + const fresh = liveMilestone(projectId); + const route = fresh.routes.find((r) => r.id === box.id); + if (!t || !route) return; + const via = t === "base"; + const now = fresh.base.enabled && route.via_base !== false; + if (via === now) return; // 같은 연결 = 그대로 + route.via_base = via; + route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 + putMilestone(projectId, fresh); + void findSegmentKm(projectId, route.id).then(toastKm); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + return dot; + }; + // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 const arrange = createButton({ label: "자동 정렬", @@ -318,6 +382,7 @@ export function createDiagram( }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); + if (box.kind === "route") g.append(connectDot(box, shift)); if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); shift.append(g); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 5a8f3e891..9b8bdf6b9 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -6,6 +6,7 @@ * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 + * 연결(기준점 거침 · 시점으로 바로) = 처음 = [+] 누른 상자 쪽 · 그 뒤 = 다이어그램 연결 점 ● 끌기(154 · 체크 없앰) * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 @@ -13,7 +14,6 @@ * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, @@ -130,9 +130,6 @@ export function openPlaceModal( const nameLabel = name.root.querySelector(".ui-field__label"); // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); - const via = createCheckbox("기준점 거침"); - via.input.checked = route?.via_base ?? true; - via.root.hidden = !route || !data.base.enabled; const old = editBase ? data.base : route; const place = placePicker( projectId, @@ -175,11 +172,10 @@ export function openPlaceModal( if (!r) return close(); Object.assign(r, where, { name: text || r.name, - via_base: via.input.checked, rows: rowValues(), }); putMilestone(projectId, fresh); - if (moved(where) || r.via_base !== route.via_base) segment = r.id; + if (moved(where)) segment = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); const viaBase = target.add && target.viaBase; @@ -219,7 +215,6 @@ export function openPlaceModal( className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), - via.root, head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, ); diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index 9a0151aca..e8fa770b8 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -104,3 +104,18 @@ def test_153_끌어_놓은_자리_레이아웃(): ) # fmt: skip assert out.returncode == 0, out.stderr assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]] + + +def test_154_연결_점_끌어_잇기(): + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui + assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui + assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 + assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 + assert "if (via === now) return;" in ui # 같은 연결 = 그대로 + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert ( + 'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal + ) + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + assert ".b08-milestone__connect {" in css and ".b08-milestone__box.is-drop-target rect {" in css From 088bb1289cad87a6685e7794d94e9771f729eee0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:20:38 +0900 Subject: [PATCH 013/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 0450eca15..545ccd4b0 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -183,3 +183,4 @@ 152. 그리고 포장과 비포장이다가 기준점이 생성되면 당연히 1차 포장, 2차 포장이 되어야할것같아. 그리고 그때 선의 연결여부에 따라 1차냐 2차냐가 되는거고 153. 카카오맵에서 기준점이나 소재지를 지정하면 보기가 초기화되고 줌아웃 상태가 되고 있어. 변화가 없는게 편할것 같아. 공사지, 기준점, 거래처를 기준으로 색상을 조금 넣어주는 좋겠어. 이때 프로그램의 내부에 화이트와 블랙테마 양식이 있으니 참고해. 다음으로 요소들을 드래그로 위치를 변경하거나 정렬하는 기능이 있으면 좋겠어. 154. (선 연결을 드래그로 — 연결 점 ● 끌어 기준점 · 공사지에 놓기 · 설명 뒤) 동의 작업진행 +155. 컨테이너 요소는 총 4개야. 3개의 컨테이너와 하나의 선 타입이야. 시점과 중점은 자동생성으로 그냥두고 기준점과 거래처의 정보를 등록하고 선으로 연결하면 모달창으로 정보가 나오는데 이때 경로를 찾은 거리와 경로를 카카오맵으로 보여주면 되는거야. 이때 문제가 있을수 있으니 수기입력 기능으로 카카오맵 연결을 취소하게 하면 될것같고 기준점과 공사지의 우측 상단의 +는 삭제하고 자동정렬 버튼 우측에 요소 추가 버튼을 만들어서 대체하게 해줘. From 2d5d357bf3149dd431c685a7080a3b617005b17f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:24:53 +0900 Subject: [PATCH 014/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20[=EC=9A=94=EC=86=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80]=20=C2=B7=20=EC=84=A0=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=83=88=20=EA=B1=B0=EB=9E=98=EC=B2=98=20=C2=B7=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=84=A0=20=EC=88=98=EA=B8=B0=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?(250-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공사지 · 기준점 [+] 없앰 → [자동 정렬] 옆 [요소 추가] - 등록 = 상자만(linked false) · 연결 점으로 이음 · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝) - 연결선 모달 [수기 입력] = 카카오 길찾기 연결 끊음(manual) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Milestone_Calc.py | 6 ++- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 10 ++++- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 6 ++- B08_Quantity/B08_Quantity_Router_Milestone.py | 2 + B08_Quantity/B08_Quantity_UI_Milestone.css | 19 +++----- .../B08_Quantity_UI_Milestone_Diagram.ts | 36 +++++---------- .../B08_Quantity_UI_Milestone_LegModal.ts | 44 ++++++++++++++----- .../B08_Quantity_UI_Milestone_Modal.ts | 13 +++--- .../B08_Quantity_UI_Milestone_State.ts | 2 + .../test_244_13_b08_milestone_place_leg.py | 2 +- .../test_244_2_b08_milestone_diagram.py | 6 +-- .../test_244_9_b08_milestone_route_map.py | 2 +- .../test_250_1_b08_milestone_roads_width.py | 27 ++++++++++++ 13 files changed, 108 insertions(+), 67 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 3030ee999..006196a3d 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -64,8 +64,10 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} - via = bool(base.get("enabled")) and route.get("via_base", True) is not False - add_legs(sums, route.get("legs"), "via" if via else "direct") + linked = route.get("linked", True) is not False # 안 이은 거래처 = 구간 km 없음(155) + via = linked and bool(base.get("enabled")) and route.get("via_base", True) is not False + if linked: + add_legs(sums, route.get("legs"), "via" if via else "direct") if via: add_legs(sums, base.get("legs"), "base") if row.get("dest") == "mid": diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index e518c7ee3..8637bc66e 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -83,6 +83,10 @@ export interface MilestoneRoute { lat?: number | null; /** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */ pos?: [number, number] | null; + /** 선 이음(155) — false = 아직 안 이음(요소 추가 뒤 · 연결 점으로 이음 · 구간 km 안 셈) · 없음 = 이음(옛 자료) */ + linked?: boolean; + /** 수기 입력(155) — 카카오 길찾기 안 함 · km 손 입력만 */ + manual?: boolean; /** 업체 → 기준점(거침) · 시점 구간 */ legs: Leg[]; rows: MilestoneRow[]; @@ -98,6 +102,7 @@ export interface MilestoneData { lon?: number | null; lat?: number | null; pos?: [number, number] | null; + manual?: boolean; /** 기준점 → 시점 공통 구간 */ legs: Leg[]; }; @@ -153,8 +158,9 @@ export function computeMilestone( for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; - const via = !!base?.enabled && route.via_base !== false; - addLegs(sums, route.legs, via ? "via" : "direct"); + const linked = route.linked !== false; // 안 이은 거래처 = 구간 km 없음(155) + const via = linked && !!base?.enabled && route.via_base !== false; + if (linked) addLegs(sums, route.legs, via ? "via" : "direct"); if (via) addLegs(sums, base.legs, "base"); if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index fcc802a0c..b6b33a7c1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -220,7 +220,7 @@ export function segmentEnds(data: MilestoneData, from: string, start: LonLat): K ? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null } : null; const route = data.routes.find((r) => r.id === from); - if (!route) return null; + if (!route || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음 const viaBase = data.base.enabled && route.via_base; return { key: "route", @@ -506,7 +506,9 @@ export function layoutMilestone( }, [ux, uy], ); - line(g.id, g.anchor === "base" ? "base" : "start", g.legs); + // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) + if (data.routes.find((r) => r.id === g.id)?.linked !== false) + line(g.id, g.anchor === "base" ? "base" : "start", g.legs); }); } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index e4f3810de..8e20c819c 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -94,12 +94,14 @@ class Place(BaseModel): lat: float | None = Field(default=None, ge=-90, le=90) # 153 다이어그램에 끌어 놓은 자리(왼 위 · 그림 좌표) — 없으면 방위 자동 자리 pos: tuple[float, float] | None = None + manual: bool = False # 155 수기 입력 — 카카오 길찾기 안 함 legs: list[Leg] = Field(default_factory=list, max_length=20) class Route(Place): id: str = Field(pattern=ROUTE_ID) via_base: bool = True + linked: bool = True # 155 False = 아직 선 안 이음(요소 추가 뒤) rows: list[Row] = Field(default_factory=list, max_length=50) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 6592fabb7..0953375dc 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -56,11 +56,13 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ -/* 153 [자동 정렬] — 다이어그램 왼쪽 위(끌어 놓은 자리가 있을 때만 · 오른쪽 위 = 진행단계 판 자리) */ -.b08-milestone__arrange { +/* 153 · 155 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 위(오른쪽 위 = 진행단계 판 자리) */ +.b08-milestone__tools { position: absolute; top: 8px; left: 8px; + display: flex; + gap: 6px; } .b08-milestone__diagram { @@ -89,8 +91,7 @@ stroke-width: 1.2; } -.b08-milestone__box text, -.b08-milestone__plus text { +.b08-milestone__box text { fill: var(--color-text); } @@ -127,16 +128,6 @@ stroke-width: 2; } -.b08-milestone__plus { - cursor: pointer; -} - -.b08-milestone__plus circle { - fill: var(--color-surface); - stroke: var(--color-accent); - stroke-width: 1.5; -} - .b08-milestone__km { fill: var(--color-text); font-size: 12px; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 0b755e5cb..37080ea36 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -161,25 +161,6 @@ export function createDiagram( base: () => openPlaceModal(projectId, { add: false, id: "base" }), }; - const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { - const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 }); - const cx = box.x + box.w - 2; - const cy = box.y + 2; - g.append( - svg("circle", { cx, cy, r: 11 }), - svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"), - svg("title", {}, "항목 추가"), - ); - g.dataset.b08MilestonePlus = viaBase ? "base" : "site"; - const go = (e: Event): void => { - e.stopPropagation(); - openPlaceModal(projectId, { add: true, viaBase }, addRoute); - }; - g.addEventListener("click", go); - g.addEventListener("keydown", (e) => e.key === "Enter" && go(e)); - return g; - }; - /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) * 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */ const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => { @@ -269,9 +250,10 @@ export function createDiagram( const route = fresh.routes.find((r) => r.id === box.id); if (!t || !route) return; const via = t === "base"; - const now = fresh.base.enabled && route.via_base !== false; + const now = route.linked === false ? null : fresh.base.enabled && route.via_base !== false; if (via === now) return; // 같은 연결 = 그대로 route.via_base = via; + route.linked = true; // 155 요소 추가 뒤 첫 이음 route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 putMilestone(projectId, fresh); void findSegmentKm(projectId, route.id).then(toastKm); @@ -295,8 +277,16 @@ export function createDiagram( putMilestone(projectId, fresh); }, }); - arrange.classList.add("b08-milestone__arrange"); arrange.dataset.b08MilestoneArrange = ""; + // 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음 + const addElem = createButton({ + label: "요소 추가", + size: "sm", + variant: "ghost", + onClick: () => openPlaceModal(projectId, { add: true }, addRoute), + }); + addElem.dataset.b08MilestoneAdd = "open"; + const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] }); const refresh = (): void => { const data = liveMilestone(projectId); @@ -383,8 +373,6 @@ export function createDiagram( g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); if (box.kind === "route") g.append(connectDot(box, shift)); - if (box.kind === "site") g.append(plus(box, false)); - if (box.kind === "base") g.append(plus(box, true)); shift.append(g); if (box.kind === "site") shift.append(lineLayer); } @@ -421,7 +409,7 @@ export function createDiagram( shift.append(text); } arrange.hidden = !data.base.pos && !data.routes.some((r) => r.pos); - host.replaceChildren(root, arrange); + host.replaceChildren(root, tools); attachSvgZoomPan({ svg: root, layer, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 98d50bffa..988a48bf3 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -11,6 +11,7 @@ * 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`) * ========================================================================== */ +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { @@ -240,20 +241,39 @@ export function openLegInfo(projectId: string, from: string): void { }); save.dataset.b08MilestoneLegSave = ""; actions.append(find, save); - body.append( - legs.root, - hint( - from === "base" - ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" - : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", - ), - status, - el("h4", { text: "경로 — 다음(카카오) 지도" }), - map.root, + // 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기 + const owner = (d = liveMilestone(projectId)) => + from === "base" ? d.base : d.routes.find((r) => r.id === from); + const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)"); + manual.input.checked = !!owner()?.manual; + manual.input.dataset.b08MilestoneManual = ""; + const auto = hint( + from === "base" + ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", ); + const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" }); + const showMode = (): void => { + const off = manual.input.checked; + find.hidden = auto.hidden = mapHead.hidden = map.root.hidden = off; + status.textContent = off ? "수기 입력 — 거리를 손으로 넣으세요" : status.textContent; + }; + manual.input.addEventListener("change", () => { + const fresh = liveMilestone(projectId); + const target = owner(fresh); + if (!target) return; + target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l)); + target.manual = manual.input.checked; + putMilestone(projectId, fresh); + redrawLegs(); + showMode(); + if (!manual.input.checked) void run(); // 다시 카카오 연결 = 길찾기 한 번 + }); + body.append(manual.root, legs.root, auto, status, mapHead, map.root); + showMode(); drawMap(); - // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음) - if (!segmentPath(from).length && seg.from && seg.to) void run(false); + // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함) + if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false); }, }); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 9b8bdf6b9..1942871d0 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -6,7 +6,8 @@ * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 - * 연결(기준점 거침 · 시점으로 바로) = 처음 = [+] 누른 상자 쪽 · 그 뒤 = 다이어그램 연결 점 ● 끌기(154 · 체크 없앰) + * 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음) + * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 @@ -84,7 +85,7 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { return { item, dest, note: r.note, setKind }; } -export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string }; +export type PlaceTarget = { add: true } | { add: false; id: string }; /** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ export function openPlaceModal( @@ -97,7 +98,7 @@ export function openPlaceModal( const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; if (!target.add && !editBase && !route) return; const title = target.add - ? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}` + ? "요소 추가 — 기준점 · 거래처" : editBase ? `기준점 — ${data.base.name || "기준점"}` : `항목 정보 — ${route!.name || "거래처"}`; @@ -178,15 +179,15 @@ export function openPlaceModal( if (moved(where)) segment = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); - const viaBase = target.add && target.viaBase; + // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) const id = addRoute?.({ name: text, - via_base: viaBase, + via_base: true, + linked: false, dir: null, rows: rowValues(), ...where, }); - if (id && where.lon != null) segment = id; } close(); if (segment) void findSegmentKm(projectId, segment).then(toastKm); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index b14239be1..9459441fa 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -239,6 +239,8 @@ export async function findSegmentKm( image = true, ): Promise { const data = liveMilestone(projectId); + const owner = from === "base" ? data.base : data.routes.find((r) => r.id === from); + if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); if (!seg) return ""; if (!seg.from || !seg.to) { diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index 5234c4374..956d4631d 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -299,7 +299,7 @@ def test_화면_등록_모달_연결선_모달(): assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid assert 'if (from === "start") return openMidLeg(projectId);' in leg assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg - assert "if (!segmentPath(from).length && seg.from && seg.to) void run(false);" in leg + assert "if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to)" in leg for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts", B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC, B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts", diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index d07f91343..203e3c163 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -145,9 +145,9 @@ def test_화면_상자_누름_더하기_창(): "openTab" not in ui and "route: (id) => openPlaceModal(projectId, { add: false, id })," in ui ) - assert 'if (box.kind === "site") g.append(plus(box, false));' in ui - assert 'if (box.kind === "base") g.append(plus(box, true));' in ui - assert "openPlaceModal(projectId, { add: true, viaBase }, addRoute)" in ui + # 155 공사지 · 기준점 [+] 없앰 → 다이어그램 [요소 추가] + assert "plus(box" not in ui and 'label: "요소 추가"' in ui + assert "openPlaceModal(projectId, { add: true }, addRoute)" in ui modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8") assert "openModal({" in modal # 149 상자 하나 = 항목 하나 — [+ 줄] · 같은 이름 합침 없음 diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 1d25f62a8..7bd1fe72e 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -216,7 +216,7 @@ def test_화면_자리_캡처_키_서버만(): assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동 leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도 - assert "map.root," in leg and "map.set(pins()" in leg + assert "mapHead, map.root" in leg and "map.set(pins()" in leg route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "routeMapView(projectId, routeId)" in route assert "mapView.draw();" in route diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index e8fa770b8..d368f1cea 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -119,3 +119,30 @@ def test_154_연결_점_끌어_잇기(): ) css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") assert ".b08-milestone__connect {" in css and ".b08-milestone__box.is-drop-target rect {" in css + + +def test_155_요소_추가_안_이은_거래처_수기_입력(): + leg = lambda road, km, auto=None: {"road": road, "km": km, "auto_km": auto} # noqa: E731 + route = {"id": "r", "name": "a", "address": "", "via_base": True, "dir": None, "linked": False, + "legs": [leg(None, 5.0)], "rows": [{"item": "장비", "dest": "mid", "note": ""}]} # fmt: skip + data = {"site": {"mid_station_m": 700, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "b", "address": "", "dir": None, "legs": [leg(None, 0.6)]}, + "routes": [route]} # fmt: skip + (a,) = compute_milestone(data, None) # 안 이음 = 구간 km 없음 · 중점 구간만 + assert (a["s2"], a["s1"], a["un"], a["total"]) == (0, 0, 0.7, 0.7) + assert _ts(f"computeMilestone({json.dumps(data)}, null)") == [a] + route["linked"] = True + (b,) = compute_milestone(data, None) + assert (b["s2"], b["s1"], b["total"]) == (5.0, 0.6, 6.3) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert "route.linked = true;" in ui and 'label: "요소 추가"' in ui and "plus(box" not in ui + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "linked: false," in modal and "export type PlaceTarget = { add: true } |" in modal + leg_ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert ( + 'createCheckbox("수기 입력' in leg_ui and "target.manual = manual.input.checked;" in leg_ui + ) + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 + layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + assert "if (!route || route.linked === false) return null;" in layout From edbce254ecf6401858951973ceaaa431a28bd94c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:24:53 +0900 Subject: [PATCH 015/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 545ccd4b0..c5a04f371 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -184,3 +184,4 @@ 153. 카카오맵에서 기준점이나 소재지를 지정하면 보기가 초기화되고 줌아웃 상태가 되고 있어. 변화가 없는게 편할것 같아. 공사지, 기준점, 거래처를 기준으로 색상을 조금 넣어주는 좋겠어. 이때 프로그램의 내부에 화이트와 블랙테마 양식이 있으니 참고해. 다음으로 요소들을 드래그로 위치를 변경하거나 정렬하는 기능이 있으면 좋겠어. 154. (선 연결을 드래그로 — 연결 점 ● 끌어 기준점 · 공사지에 놓기 · 설명 뒤) 동의 작업진행 155. 컨테이너 요소는 총 4개야. 3개의 컨테이너와 하나의 선 타입이야. 시점과 중점은 자동생성으로 그냥두고 기준점과 거래처의 정보를 등록하고 선으로 연결하면 모달창으로 정보가 나오는데 이때 경로를 찾은 거리와 경로를 카카오맵으로 보여주면 되는거야. 이때 문제가 있을수 있으니 수기입력 기능으로 카카오맵 연결을 취소하게 하면 될것같고 기준점과 공사지의 우측 상단의 +는 삭제하고 자동정렬 버튼 우측에 요소 추가 버튼을 만들어서 대체하게 해줘. +156. 거리이정표탭의 하위 탭중 거래처 항목 탭의 경우 내부에 길찾기가 있는데 이항목은 이제 없어도 될것같은데 어떻게 생각함? 필요없는 로직으로 보임. From eaadf2778282ac884f7d6ac3e809f4e2351f0a47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:27:46 +0900 Subject: [PATCH 016/151] =?UTF-8?q?refactor(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EA=B1=B0=EB=9E=98=EC=B2=98=20?= =?UTF-8?q?=ED=83=AD=20=3D=20=EA=B7=B8=EB=A6=BC=EB=A7=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=83=88=20=EC=B0=BD=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=ED=83=AD=20=EC=A7=80=EB=8F=84=20=EC=B9=B8=20=EC=97=86=EC=95=B0?= =?UTF-8?q?=20(250-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 7 -- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 89 +------------------ .../B08_Quantity_UI_Milestone_Route.ts | 47 +--------- .../test_244_10_b08_milestone_tailscale.py | 7 +- .../tester/test_244_4_b08_milestone_modal.py | 15 +--- .../test_244_9_b08_milestone_route_map.py | 12 ++- 6 files changed, 24 insertions(+), 153 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index b6b33a7c1..d3f7016da 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -530,13 +530,6 @@ export function layoutMilestone( }; } -/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */ -export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string { - const part = (p: { name: string; lon: number; lat: number }): string => - `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; - return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; -} - /** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index e30eb7972..6a4980ccf 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -1,19 +1,17 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_KakaoMap.ts - * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만). + * 거리이정표 카카오 지도 칸(PLAN 244-9 · 사용자 138번) — 등록 모달(소재지 고르기) · 연결선 모달(길 선 · 표지 끌기) + * 한 벌(`editMap`) · 경로 하위 탭 지도 칸은 없앰(156 · 탭 = 그림만). * * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 - * 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 · - * 시점(`mapMarks`) · 경로 전체 맞춤 + * 길 선 = 마지막 [길찾기] 응답 경로 좌표(메모리만 · 저장 안 함) · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로) * 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관) * (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import { mapMarks } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; -import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State"; export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; @@ -93,87 +91,6 @@ export function loadKakaoMaps(projectId: string): Promise { return sdk; } -export interface RouteMapView { - root: HTMLElement; - /** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */ - draw: () => void; -} - -/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */ -export function routeMapView(projectId: string, routeId: string): RouteMapView { - const box = el("div", { className: "b08-milestone__map" }); - const hint = el("p", { className: "b08-options__hint" }); - hint.dataset.b08MilestoneMapHint = ""; - let map: KMap | null = null; - let drawn: KOverlay[] = []; - let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로) - - const draw = (): void => { - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); - const lines = routePaths(routeId); - const sig = JSON.stringify([marks, lines]); - if (map && sig === shown) return; - loadKakaoMaps(projectId).then( - (maps) => - requestAnimationFrame(() => { - const points = [ - ...marks.map((m) => new maps.LatLng(m.lat, m.lon)), - ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), - ]; - if (!points.length) { - hint.textContent = "거래처 주소 · 노선 시점이 있어야 지도에 놓임"; - return; - } - box.hidden = false; - map ??= new maps.Map(box, { center: points[0], level: 7 }); - map.relayout(); - drawn.forEach((o) => o.setMap(null)); - drawn = [ - ...lines.map( - (line) => - new maps.Polyline({ - path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), - strokeWeight: 6, - strokeColor: LINE_COLOR, - strokeOpacity: 0.9, - }), - ), - ...marks.flatMap((m) => { - const at = new maps.LatLng(m.lat, m.lon); - return [ - new maps.Marker({ position: at, title: m.name }), - new maps.CustomOverlay({ - position: at, - content: el("span", { className: "b08-milestone__map-label", text: m.name }), - yAnchor: 2.6, - }), - ]; - }), - ]; - drawn.forEach((o) => o.setMap(map)); - const bounds = new maps.LatLngBounds(); - points.forEach((p) => bounds.extend(p)); - map.setBounds(bounds); - shown = sig; - hint.textContent = lines.length - ? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" - : "[길찾기]를 누르면 길 선 · 경로 그림이 생김"; - }), - (error: unknown) => { - box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로) - hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; - }, - ); - }; - - const root = el("div", { - className: "b08-milestone__map-wrap", - children: [hint, box], - }); - draw(); - return { root, draw }; -} - /** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */ export interface MapPin { key: string; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index d61fee88b..655e4724b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -5,9 +5,7 @@ * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 - * [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) · - * 도착 = 목적지 좌표) — 보기만 - * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄 + * 길찾기 · 카카오 지도는 연결선 모달 몫(156 · 이 탭은 그림만 · 새 창 길찾기 · 지도 칸 없앰) * ========================================================================== */ import { @@ -17,7 +15,6 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; -import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout"; import { autoImageNote, canRevertImage, @@ -26,10 +23,8 @@ import { liveMilestone, putMilestone, routeImageKey, - siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; -import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; let menu: MapContextMenu | null = null; let lastCell: Element | null = null; @@ -70,26 +65,10 @@ export interface MilestoneView { refresh: () => void; } -/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ -export function routePoints( - d: MilestoneData, - r: MilestoneRoute, -): Array<{ name: string; lon: number; lat: number }> | null { - const site = siteInfo(); - const dest = r.rows[0]?.dest ?? "start"; - const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat; - if (r.lon == null || r.lat == null || !to) return null; - const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }]; - if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null) - points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat }); - points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] }); - return points; -} - export function routeView(projectId: string, routeId: string): MilestoneView { const title = el("h4"); const HINT = - "경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; + "경로 그림 = 연결선 길찾기 때 서버가 카카오 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; const hint = el("p", { className: "b08-options__hint", text: HINT }); hint.dataset.b08MilestoneRouteHint = ""; @@ -124,23 +103,6 @@ export function routeView(projectId: string, routeId: string): MilestoneView { if (picked) void put(picked); }); - const kakao = createButton({ - label: "다음 지도에서 길찾기", - size: "sm", - variant: "filled", - onClick: () => { - const d = liveMilestone(projectId); - const r = d.routes.find((x) => x.id === routeId); - const points = r ? routePoints(d, r) : null; - if (!points) - return showToast( - "거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", - "info", - ); - window.open(kakaoRouteUrl(points), "_blank", "noopener"); - }, - }); - kakao.dataset.b08MilestoneKakao = ""; const upload = createButton({ label: "그림 올리기", size: "sm", @@ -167,7 +129,6 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); - const mapView = routeMapView(projectId, routeId); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, @@ -175,10 +136,9 @@ export function routeView(projectId: string, routeId: string): MilestoneView { title, el("div", { className: "b08-milestone__bar", - children: [kakao, upload, revert, removeImage, file], + children: [upload, revert, removeImage, file], }), hint, - mapView.root, noImage, img, ], @@ -193,7 +153,6 @@ export function routeView(projectId: string, routeId: string): MilestoneView { loadImage(); revert.hidden = !canRevertImage(routeId); hint.textContent = autoImageNote(routeId) ?? HINT; - mapView.draw(); }; refresh(); return { root, refresh }; diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index b1d8968e3..bed1745c6 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -38,7 +38,8 @@ const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]]; FAKE_KAKAO = """ class Fake { setMap() {} relayout() {} setBounds() {} extend() {} } (globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake, - LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake } }; + LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake, + event: { addListener: () => {} } } }; """ @@ -48,7 +49,9 @@ def _view(setup): + setup + IMPORTS.sub("", _read(KMAP)) + """ -const view = routeMapView("p1", "r1"); +// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap) +const view = editMap("p1", { onMove: () => {}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }); +view.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }], routePaths()); await new Promise((r) => setTimeout(r, 30)); const [hint, box] = view.root.children; console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 5f8db74f8..44cb13b38 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -76,16 +76,10 @@ def test_방위_좌표_자동(): def test_다음_지도_길찾기_주소(): - pts = [{"name": "거창군청", "lon": 127.909, "lat": 35.686}, - {"name": "병곡리, 968/31", "lon": 127.75, "lat": 35.78}, - {"name": "임도 시점", "lon": 127.74, "lat": 35.79}] # fmt: skip - url = _node(f"kakaoRouteUrl({json.dumps(pts, ensure_ascii=False)})") - # 공식 꼴 /link/by/car/이름,위도,경도/… · 이름의 쉼표 · 빗금은 빈칸 - assert url == ( - "https://map.kakao.com/link/by/car/%EA%B1%B0%EC%B0%BD%EA%B5%B0%EC%B2%AD,35.686,127.909/" - "%EB%B3%91%EA%B3%A1%EB%A6%AC%20%20968%2031,35.78,127.75/" - "%EC%9E%84%EB%8F%84%20%EC%8B%9C%EC%A0%90,35.79,127.74" - ) + # 156 경로 탭 새 창 길찾기 없앰(길찾기 = 연결선 모달 · 카카오 길찾기 API) — 주소 만들기도 없음 + assert "kakaoRouteUrl" not in _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "window.open(" not in route and "다음 지도에서 길찾기" not in route def test_VWorld_좌표_주소(monkeypatch): @@ -174,7 +168,6 @@ def test_화면_모달_팬줌_그림탭(): assert ( "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] ) - assert "window.open(kakaoRouteUrl(points)" in route assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route assert 'label: "그림 올리기"' in route page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 7bd1fe72e..fc797d644 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -159,7 +159,11 @@ const PATHS = {json.dumps(paths)}; const asked: string[] = []; const request = async (url: string) => (asked.push(url), {{ key: KEY }}); {extra} -const view = routeMapView("p1", "r1"); +// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap) +const view = editMap("p1", {{ onMove: () => {{}}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }}); +view.set([{{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }}, + {{ key: "base", name: "병곡리", lon: 127.85, lat: 35.66, movable: true }}, + {{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }}], PATHS); await new Promise((r) => setTimeout(r, 30)); const [hint, box] = view.root.children; console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden, @@ -193,6 +197,7 @@ const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.pu Polyline: function (o: any) { return on("line", o); }, Marker: function (o: any) { return on("marker", o); }, CustomOverlay: function (o: any) { return on("label", o); }, + event: { addListener: () => {} }, } }; """ @@ -218,8 +223,9 @@ def test_화면_자리_캡처_키_서버만(): leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도 assert "mapHead, map.root" in leg and "map.set(pins()" in leg route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") - assert "routeMapView(projectId, routeId)" in route - assert "mapView.draw();" in route + # 156 경로 탭 = 그림만(새 창 길찾기 · 지도 칸 없앰 · 길찾기 · 지도 = 연결선 모달) + assert "routeMapView" not in route and "다음 지도에서 길찾기" not in route + assert "routeMapView" not in kmap state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감) for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만 From e6be11cdd9afec48e35072a53a3eb7a2812ce190 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:41:01 +0900 Subject: [PATCH 017/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EB=8F=84=EA=B5=AC=20=EC=99=BC?= =?UTF-8?q?=EC=AA=BD=20=EC=95=84=EB=9E=98=20=C2=B7=20=EA=B1=B0=EB=9E=98?= =?UTF-8?q?=EC=B2=98=20=ED=83=AD=20=3D=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20+=20=EA=B7=B8=EB=A6=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=C2=B7=20=EC=B4=88=EA=B8=B0=ED=99=94=20(250-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .../B08_Quantity_Milestone_Capture.py | 2 +- B08_Quantity/B08_Quantity_UI_Milestone.css | 24 ++- .../B08_Quantity_UI_Milestone_Diagram.ts | 2 - .../B08_Quantity_UI_Milestone_KakaoMap.ts | 4 +- .../B08_Quantity_UI_Milestone_Route.ts | 151 +++++++++++------- .../B08_Quantity_UI_Milestone_State.ts | 20 +-- .../test_244_10_b08_milestone_tailscale.py | 2 +- .../test_244_11_b08_milestone_auto_capture.py | 13 +- .../tester/test_244_4_b08_milestone_modal.py | 2 +- .../test_244_9_b08_milestone_route_map.py | 2 +- .../test_249_1_b08_milestone_capture_child.py | 2 +- .../test_250_1_b08_milestone_roads_width.py | 20 +++ 12 files changed, 154 insertions(+), 90 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index ca6a9cff1..7a3b50975 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -36,7 +36,7 @@ CHILD = __file__ # 자식으로 돌릴 스크립트 NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 -TAIL = " — 그림 올리기를 쓰세요." +TAIL = " — [그림 변경]으로 넣으세요." _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 0953375dc..6f1c7fd0a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -56,10 +56,10 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ -/* 153 · 155 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 위(오른쪽 위 = 진행단계 판 자리) */ +/* 153 · 155 · 157 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 아래 · 단추 = 메인 탭 크기 */ .b08-milestone__tools { position: absolute; - top: 8px; + bottom: 8px; left: 8px; display: flex; gap: 6px; @@ -248,3 +248,23 @@ stroke-width: 3; stroke-dasharray: none; } + +/* 157 경로 탭 머리 줄 — 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 */ +.b08-milestone__route-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); +} + +.b08-milestone__route-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8) var(--spacing-16); + font-size: var(--text-body-sm); +} + +.b08-milestone__route-addr { + color: var(--color-text-secondary); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 37080ea36..282856e60 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -267,7 +267,6 @@ export function createDiagram( // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 const arrange = createButton({ label: "자동 정렬", - size: "sm", variant: "ghost", onClick: () => { const fresh = liveMilestone(projectId); @@ -281,7 +280,6 @@ export function createDiagram( // 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음 const addElem = createButton({ label: "요소 추가", - size: "sm", variant: "ghost", onClick: () => openPlaceModal(projectId, { add: true }, addRoute), }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 6a4980ccf..956d534ff 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -14,7 +14,7 @@ import { el } from "@ui/ui_template_elements"; import { request } from "./B08_Quantity_UI_Hydro"; export const NO_MAP = - "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요"; const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -74,7 +74,7 @@ export function loadKakaoMaps(projectId: string): Promise { fail( Object.assign( new Error( - `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`, + `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요`, ), { name: SDK_FAIL }, ), diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 655e4724b..606fa3c38 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -1,11 +1,10 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Route.ts - * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). + * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4 · 157) — 머리 줄(거래처 이름 · 주소 + [그림 변경] · [초기화]) + + * 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림을 올리면 그 그림 · [초기화] = 지도로 * - * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 - * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 - * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 - * 길찾기 · 카카오 지도는 연결선 모달 몫(156 · 이 탭은 그림만 · 새 창 길찾기 · 지도 칸 없앰) + * [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버) + * 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157) * ========================================================================== */ import { @@ -15,14 +14,18 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { - autoImageNote, - canRevertImage, deleteImage, + findSegmentKm, + hasUserImage, imageBlobUrl, liveMilestone, putMilestone, routeImageKey, + segmentPath, + siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -66,94 +69,130 @@ export interface MilestoneView { } export function routeView(projectId: string, routeId: string): MilestoneView { - const title = el("h4"); - const HINT = - "경로 그림 = 연결선 길찾기 때 서버가 카카오 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; - const hint = el("p", { className: "b08-options__hint", text: HINT }); - hint.dataset.b08MilestoneRouteHint = ""; + // 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 + const name = el("strong", { className: "b08-milestone__route-name" }); + const address = el("span", { className: "b08-milestone__route-addr" }); - // 그림 — 경로 하나 = 그림 하나 + // 몸 = 사용자 그림 있으면 그 그림 · 없으면 길찾기 지도(연결선 모달과 같은 지도 · 표지 끌어 소재지 옮김) const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); - const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" }); let shown: string | null | undefined; - const loadImage = (force = false): void => { - const name = routeImageKey(routeId); - if (!force && name === shown) return; - shown = name; - img.hidden = !name; - noImage.hidden = !!name; + const loadImage = (): void => { + const key = hasUserImage(routeId) ? routeImageKey(routeId) : null; + img.hidden = !key; + if (key === shown) return; + shown = key; if (img.src) URL.revokeObjectURL(img.src); img.removeAttribute("src"); - if (name) + if (key) imageBlobUrl(projectId, routeId).then( (url) => (img.src = url), (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), ); }; - const put = (picked: File): Promise => - uploadImage(projectId, routeId, picked).then( - () => loadImage(true), - (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), - ); const file = el("input", { attrs: { type: "file", accept: "image/*" } }); file.hidden = true; file.addEventListener("change", () => { const picked = file.files?.[0]; file.value = ""; - if (picked) void put(picked); + if (picked) + void uploadImage(projectId, routeId, picked).then( + () => refresh(), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); }); - - const upload = createButton({ - label: "그림 올리기", - size: "sm", + const change = createButton({ + label: "그림 변경", variant: "ghost", onClick: () => file.click(), }); - const revert = createButton({ - label: "자동 그림으로 되돌리기", - size: "sm", + change.dataset.b08MilestoneImageChange = ""; + // 초기화 = 사용자 그림만 지우고 길찾기 지도로 되돌림 + const reset = createButton({ + label: "초기화", variant: "ghost", onClick: () => - void deleteImage(projectId, routeId, true).catch((error: unknown) => - showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"), + void deleteImage(projectId, routeId, true).then( + () => refresh(), + (error: unknown) => showToast(`초기화하지 못했습니다 — ${String(error)}`, "error"), ), }); - revert.dataset.b08MilestoneRevert = ""; - const removeImage = createButton({ - label: "그림 지우기", - size: "sm", - variant: "ghost", - onClick: () => - void deleteImage(projectId, routeId).catch((error: unknown) => - showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"), - ), + reset.dataset.b08MilestoneRevert = ""; + + const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const pins = (): MapPin[] => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const out: MapPin[] = []; + const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => { + if (lon != null && lat != null) + out.push({ key, name: label, lon, lat, movable: key !== "start" }); + }; + if (!r) return out; + add(r.id, r.name || "거래처", r.lon, r.lat); + if (r.linked === false) return out; + if (d.base.enabled && r.via_base) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + add("start", "시점", start()?.[0], start()?.[1]); + return out; + }; + const lines = (): Array> => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const via = !!r && r.linked !== false && d.base.enabled && r.via_base; + return [segmentPath(routeId), ...(via ? [segmentPath("base")] : [])].filter( + (p) => p.length >= 2, + ); + }; + const map = editMap(projectId, { + hint: "", + onMove: (key, lon, lat) => + void reverseAddress(projectId, lon, lat).then((addr) => { + const fresh = liveMilestone(projectId); + const target = key === "base" ? fresh.base : fresh.routes.find((x) => x.id === key); + if (!target) return; + Object.assign(target, { lon, lat, address: addr }); + putMilestone(projectId, fresh); + void findSegmentKm(projectId, key === "base" ? "base" : routeId).then(() => drawMap()); + }), }); + const drawMap = (): void => map.set(pins(), lines()); + let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음) + const ensurePath = (): void => { + if (asked || segmentPath(routeId).length) return; + asked = true; + void findSegmentKm(projectId, routeId, false).then(() => drawMap()); + }; const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, children: [ - title, el("div", { - className: "b08-milestone__bar", - children: [upload, revert, removeImage, file], + className: "b08-milestone__route-head", + children: [ + el("div", { className: "b08-milestone__route-title", children: [name, address] }), + el("div", { className: "b08-milestone__bar", children: [change, reset, file] }), + ], }), - hint, - noImage, img, + map.root, ], }); - const refresh = (): void => { + function refresh(): void { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); if (!r) return; - const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; - title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; + name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`; + address.textContent = r.address || ""; loadImage(); - revert.hidden = !canRevertImage(routeId); - hint.textContent = autoImageNote(routeId) ?? HINT; - }; + const user = hasUserImage(routeId); + reset.hidden = !user; + map.root.hidden = user; + if (!user) { + drawMap(); + ensurePath(); + } + } refresh(); return { root, refresh }; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 9459441fa..456a44b57 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -81,11 +81,10 @@ export function siteAddress(projectId: string, midM: number | null): SiteAddress } export const routeImages = (): Record => saved?.images ?? {}; -/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ -export const canRevertImage = (routeId: string): boolean => { +/** 사용자가 올린 그림이 있음(157 · 자동 그림과 다른 이름) — 경로 탭 = 그 그림 · 없으면 길찾기 지도 */ +export const hasUserImage = (routeId: string): boolean => { const name = saved?.images[routeId]; - const auto = saved?.auto_images?.[routeId]; - return !!name && !!auto && name !== auto; + return !!name && name !== saved?.auto_images?.[routeId]; }; /** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ @@ -95,10 +94,6 @@ export const routeImageKey = (routeId: string): string | null => { return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null; }; -/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */ -const autoNotes = new Map(); -export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null; - export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; @@ -189,8 +184,6 @@ export async function autoRouteImage( 0, 2, ); - autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); - changes.dispatchEvent(new Event("change")); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( @@ -205,12 +198,9 @@ export async function autoRouteImage( imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); } } catch (error) { - reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`; + reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — [그림 변경]으로 넣으세요.`; } - if (reason) { - autoNotes.set(routeId, reason); - showToast(reason, "warning"); - } else autoNotes.delete(routeId); + if (reason) showToast(reason, "warning"); changes.dispatchEvent(new Event("change")); } diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index bed1745c6..9d4f13294 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -89,6 +89,6 @@ def test_SDK_401_도메인_등록_안내(): got = _view(NO_MEDIA + sdk_401) # window.kakao 없음 → script 넣음 → 401 = onerror assert len(got["injected"]) == 1 and "sdk.js?appkey=fake-js-key" in got["injected"][0] assert got["hint"] == ( - f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" ) assert got["boxHidden"] diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 25a591302..4efdac4de 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -201,7 +201,7 @@ def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): def test_실패_까닭_한_줄(tmp_path, monkeypatch): - tail = " — 그림 올리기를 쓰세요." + tail = " — [그림 변경]으로 넣으세요." cases = { "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", @@ -269,14 +269,11 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state assert "user_only=true" in state and "showToast(reason," in state route = read("B08_Quantity_UI_Milestone_Route.ts") - assert ( - 'label: "자동 그림으로 되돌리기"' in route - and "deleteImage(projectId, routeId, true)" in route - ) - assert "revert.hidden = !canRevertImage(routeId);" in route - assert "hint.textContent = autoImageNote(routeId) ?? HINT;" in route + # 157 [초기화] = 사용자 그림만 지우고 길찾기 지도로 · 쓸데없는 안내 줄 없음 + assert 'label: "초기화"' in route and "deleteImage(projectId, routeId, true)" in route + assert "reset.hidden = !user;" in route and "HINT" not in route kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") - assert "등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 + assert "등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 big = ("Milestone_Capture.py", "Router_Milestone.py", "UI_Milestone_State.ts") for name in (f"B08_Quantity_{n}" for n in big): assert len(read(name).splitlines()) <= 700 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 44cb13b38..613e5d68d 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -169,7 +169,7 @@ def test_화면_모달_팬줌_그림탭(): "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] ) assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route - assert 'label: "그림 올리기"' in route + assert 'label: "그림 변경"' in route # 157 그림 올리기 → 그림 변경 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page for rel in ("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index fc797d644..8f0393f93 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -177,7 +177,7 @@ def test_키_없음_안내_한_줄(): assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] assert got["injected"] == [] # SDK 안 부름 assert got["hint"] == ( - "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" ) assert got["boxHidden"] diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py index d567527cc..2e1948356 100644 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -81,7 +81,7 @@ def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch): def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch): monkeypatch.setenv(cap.KAKAO_JS, KEY) - tail = " — 그림 올리기를 쓰세요." + tail = " — [그림 변경]으로 넣으세요." head = "경로 그림 자동 캡처 실패 — " hang = tmp_path / "hang.py" hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index d368f1cea..9ff09d1d8 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -146,3 +146,23 @@ def test_155_요소_추가_안_이은_거래처_수기_입력(): assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") assert "if (!route || route.linked === false) return null;" in layout + + +def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + tools = css[css.index(".b08-milestone__tools {") :] + assert "bottom: 8px;" in tools[: tools.index("}")] and "left: 8px;" in tools[: tools.index("}")] + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert ( + 'label: "요소 추가",\n size: "sm"' not in ui + and 'label: "자동 정렬",\n size: "sm"' not in ui + ) + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert 'label: "그림 변경"' in route and 'label: "초기화"' in route + assert "그림 지우기" not in route and "자동 그림으로 되돌리기" not in route.split("*/", 1)[1] + assert ( + "map.root.hidden = user;" in route and "editMap(projectId" in route + ) # 그림 없으면 길찾기 지도 + assert ".b08-milestone__route-head {" in css and "justify-content: space-between;" in css + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "export const hasUserImage" in state and "canRevertImage" not in state From bde0b0098b67e5693c0462e94432ba6f74bfce4e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:41:01 +0900 Subject: [PATCH 018/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20156~158?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index c5a04f371..30fb84c0b 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -185,3 +185,5 @@ 154. (선 연결을 드래그로 — 연결 점 ● 끌어 기준점 · 공사지에 놓기 · 설명 뒤) 동의 작업진행 155. 컨테이너 요소는 총 4개야. 3개의 컨테이너와 하나의 선 타입이야. 시점과 중점은 자동생성으로 그냥두고 기준점과 거래처의 정보를 등록하고 선으로 연결하면 모달창으로 정보가 나오는데 이때 경로를 찾은 거리와 경로를 카카오맵으로 보여주면 되는거야. 이때 문제가 있을수 있으니 수기입력 기능으로 카카오맵 연결을 취소하게 하면 될것같고 기준점과 공사지의 우측 상단의 +는 삭제하고 자동정렬 버튼 우측에 요소 추가 버튼을 만들어서 대체하게 해줘. 156. 거리이정표탭의 하위 탭중 거래처 항목 탭의 경우 내부에 길찾기가 있는데 이항목은 이제 없어도 될것같은데 어떻게 생각함? 필요없는 로직으로 보임. +157. 1. 요소추가 버튼은 다이어그램 영역의 좌측 하단으로 이동하고 버튼사이즈는 메인 탭 제목과 같은 사이즈양식으로 변경, 자동정렬도 같이 이동 2. 하위 거래처 탭에서 보이는 정보중에 그림 올리기 버튼은 그림 변경으로 이름 변경, 그림 지우기 버튼은 필요 없으나 초기화 버튼으로 사용자가 올린그림을 지우고 원래연결되 그림으로 되돌리는 기능으로 변경. 이름은 초기화. 3. 하위 거래처 탭에서 내가 보기에는 이제 그림으로 표현할 필요는 없어보이는데? 거리 이정표에서 선을 선택할 때 보이는 길찾기 화면을 보여주는게 제어도 되고 좋을것같음. 단 사용자가 그림 변경하여 업로드하면 변경한 그림을 보여주고 초기화하면 다시 길찾기 화면을 보여주면 됨. 추가로 쓸데없는 설명은 삭제 그림 변경과 초기화 버튼은 거래처명이 작성된 라벨과 같은행에 우측맞춤하여 배치, 거래처명, 주소, 버튼의 글자는 크게 변경, 메인 탭 사이즈와 동일하게 +158. 자동정렬의 기준이 뭐임? 혹시 각각 요소들의 위치를 나타내는건가? (질문) From 35aaed1506f6e067fa52028d11538195cc86fe04 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:59:31 +0900 Subject: [PATCH 019/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20159?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 30fb84c0b..002a0705d 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -187,3 +187,4 @@ 156. 거리이정표탭의 하위 탭중 거래처 항목 탭의 경우 내부에 길찾기가 있는데 이항목은 이제 없어도 될것같은데 어떻게 생각함? 필요없는 로직으로 보임. 157. 1. 요소추가 버튼은 다이어그램 영역의 좌측 하단으로 이동하고 버튼사이즈는 메인 탭 제목과 같은 사이즈양식으로 변경, 자동정렬도 같이 이동 2. 하위 거래처 탭에서 보이는 정보중에 그림 올리기 버튼은 그림 변경으로 이름 변경, 그림 지우기 버튼은 필요 없으나 초기화 버튼으로 사용자가 올린그림을 지우고 원래연결되 그림으로 되돌리는 기능으로 변경. 이름은 초기화. 3. 하위 거래처 탭에서 내가 보기에는 이제 그림으로 표현할 필요는 없어보이는데? 거리 이정표에서 선을 선택할 때 보이는 길찾기 화면을 보여주는게 제어도 되고 좋을것같음. 단 사용자가 그림 변경하여 업로드하면 변경한 그림을 보여주고 초기화하면 다시 길찾기 화면을 보여주면 됨. 추가로 쓸데없는 설명은 삭제 그림 변경과 초기화 버튼은 거래처명이 작성된 라벨과 같은행에 우측맞춤하여 배치, 거래처명, 주소, 버튼의 글자는 크게 변경, 메인 탭 사이즈와 동일하게 158. 자동정렬의 기준이 뭐임? 혹시 각각 요소들의 위치를 나타내는건가? (질문) +159. (원문) 1. 자동정렬 명칭을 방위배치로 변경(방위 배치 안보이고 테이블이 브라우져 폭을 다사용하게 반영 안됨) 2. 이제 연결작업을 할 생각임. 요소추가 버튼으로 생성할 수 있어야하지만 기본적으로 프로젝트의 사용하고 있는 구조물조합의 일위대가의 자재목록을 하나의 행으로 구성되어야함. - 초기에는 거래처와 주소가 지정되지 않은 컨테이너를 다이어그램에 배치해주고 사용자가 컨테이너의 모달로 접근하여 정보입력하게 됨. - 이후 선을 연결하여 정보 연결을 마무리함. 3. 이후 사용자는 단가산출 로직이나 마스터/회사/개인의 일위대가 사전조합을 연결한다. 이때 연결여부 표현을 위해 (구조물 집계표 측점 램프 그림) 항목 앞에 램프를 추가함. 4. 이 정보들은 모여서 B09의 운반공 하위의 항목으로 각 행들이 들어간다. 이때 B08에서 연결이 안되어 있더라도 설계내역서 행의 기능 중 연결 기능으로 단가산출이나 일위대가 사전조합을 연결할 수 있어야함. 5. 설계내역서/거리이정표의 마우스 우클릭의 기능 중 연결에서 여러정보들이 나오는데 이렇게 하지말고 모달창을 띄워서 보여주고 한번에 검색하거나 필터링하여 검색할수 있는 기능 부여. 이때 연결이라는 명칭보다는 단가연결 이라고 변경하여 표현 6. 연결은 중요한 문제니 너가 직접 조사 작업을 하여 계획서를 작성하고 작업을 이어서 해줘. From 252a49e916874d74eea48724209c9debc50ea91a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:00:26 +0900 Subject: [PATCH 020/151] =?UTF-8?q?fix(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20[=EC=9E=90=EB=8F=99=20=EC=A0=95=EB=A0=AC]?= =?UTF-8?q?=20=E2=86=92=20[=EB=B0=A9=EC=9C=84=20=EB=B0=B0=EC=B9=98]=20?= =?UTF-8?q?=C2=B7=20=EB=8A=98=20=EB=B3=B4=EC=9E=84=20(159-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_UI_Milestone.css | 2 +- B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts | 5 ++--- resources/tester/test_250_1_b08_milestone_roads_width.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 6f1c7fd0a..2a210aa61 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -56,7 +56,7 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ -/* 153 · 155 · 157 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 아래 · 단추 = 메인 탭 크기 */ +/* 153 · 155 · 157 · 159 [방위 배치] · [요소 추가] — 다이어그램 왼쪽 아래 · 단추 = 메인 탭 크기 */ .b08-milestone__tools { position: absolute; bottom: 8px; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 282856e60..dc44b57f7 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -264,9 +264,9 @@ export function createDiagram( return dot; }; - // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 + // 153 · 159 [방위 배치] — 끌어 놓은 자리 모두 지우고 방위(소재지 좌표) 자동 자리로 · 늘 보임 const arrange = createButton({ - label: "자동 정렬", + label: "방위 배치", variant: "ghost", onClick: () => { const fresh = liveMilestone(projectId); @@ -406,7 +406,6 @@ export function createDiagram( }); shift.append(text); } - arrange.hidden = !data.base.pos && !data.routes.some((r) => r.pos); host.replaceChildren(root, tools); attachSvgZoomPan({ svg: root, diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index 9ff09d1d8..d444804b8 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -87,7 +87,7 @@ def test_153_지도_보기_유지_상자_색_끌어_옮김(): assert var in block[: block.index("}")], kind ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui - assert 'label: "자동 정렬"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + assert 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui # 159 이름 def test_153_끌어_놓은_자리_레이아웃(): @@ -155,7 +155,7 @@ def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert ( 'label: "요소 추가",\n size: "sm"' not in ui - and 'label: "자동 정렬",\n size: "sm"' not in ui + and 'label: "방위 배치",\n size: "sm"' not in ui ) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert 'label: "그림 변경"' in route and 'label: "초기화"' in route From 1cd614da1423f99c9d8c7a8ed3f9ecabe62a96b4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:05:31 +0900 Subject: [PATCH 021/151] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20160?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 002a0705d..c9ed5f4ea 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -188,3 +188,4 @@ 157. 1. 요소추가 버튼은 다이어그램 영역의 좌측 하단으로 이동하고 버튼사이즈는 메인 탭 제목과 같은 사이즈양식으로 변경, 자동정렬도 같이 이동 2. 하위 거래처 탭에서 보이는 정보중에 그림 올리기 버튼은 그림 변경으로 이름 변경, 그림 지우기 버튼은 필요 없으나 초기화 버튼으로 사용자가 올린그림을 지우고 원래연결되 그림으로 되돌리는 기능으로 변경. 이름은 초기화. 3. 하위 거래처 탭에서 내가 보기에는 이제 그림으로 표현할 필요는 없어보이는데? 거리 이정표에서 선을 선택할 때 보이는 길찾기 화면을 보여주는게 제어도 되고 좋을것같음. 단 사용자가 그림 변경하여 업로드하면 변경한 그림을 보여주고 초기화하면 다시 길찾기 화면을 보여주면 됨. 추가로 쓸데없는 설명은 삭제 그림 변경과 초기화 버튼은 거래처명이 작성된 라벨과 같은행에 우측맞춤하여 배치, 거래처명, 주소, 버튼의 글자는 크게 변경, 메인 탭 사이즈와 동일하게 158. 자동정렬의 기준이 뭐임? 혹시 각각 요소들의 위치를 나타내는건가? (질문) 159. (원문) 1. 자동정렬 명칭을 방위배치로 변경(방위 배치 안보이고 테이블이 브라우져 폭을 다사용하게 반영 안됨) 2. 이제 연결작업을 할 생각임. 요소추가 버튼으로 생성할 수 있어야하지만 기본적으로 프로젝트의 사용하고 있는 구조물조합의 일위대가의 자재목록을 하나의 행으로 구성되어야함. - 초기에는 거래처와 주소가 지정되지 않은 컨테이너를 다이어그램에 배치해주고 사용자가 컨테이너의 모달로 접근하여 정보입력하게 됨. - 이후 선을 연결하여 정보 연결을 마무리함. 3. 이후 사용자는 단가산출 로직이나 마스터/회사/개인의 일위대가 사전조합을 연결한다. 이때 연결여부 표현을 위해 (구조물 집계표 측점 램프 그림) 항목 앞에 램프를 추가함. 4. 이 정보들은 모여서 B09의 운반공 하위의 항목으로 각 행들이 들어간다. 이때 B08에서 연결이 안되어 있더라도 설계내역서 행의 기능 중 연결 기능으로 단가산출이나 일위대가 사전조합을 연결할 수 있어야함. 5. 설계내역서/거리이정표의 마우스 우클릭의 기능 중 연결에서 여러정보들이 나오는데 이렇게 하지말고 모달창을 띄워서 보여주고 한번에 검색하거나 필터링하여 검색할수 있는 기능 부여. 이때 연결이라는 명칭보다는 단가연결 이라고 변경하여 표현 6. 연결은 중요한 문제니 너가 직접 조사 작업을 하여 계획서를 작성하고 작업을 이어서 해줘. +160. 추가로 단가연결을 할때는 구조물조합이나 일위대가 사전조합이라면 문제가 없겠지만 단가산출의 경우에는 연결할때 옵션값을 받아야하는 경우가 있을거야. 이때는 추가 모달창을 보여줘서 사용자가 선택할수 있게 반영해줘. 만약 내가 잘못알고 있는거라면 알아서 해주고 From 6fa684e0fe34b9f4822ef296cd3963ec5606343b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:07:09 +0900 Subject: [PATCH 022/151] =?UTF-8?q?chore(plan):=2010-11=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20AI(=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95=ED=91=9C=20?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=20=ED=96=89=20=C2=B7=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=97=B0=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/조사_AI_단가연결.md | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AI_단가연결.md diff --git a/docs/raw/요구_1010_2/조사_AI_단가연결.md b/docs/raw/요구_1010_2/조사_AI_단가연결.md new file mode 100644 index 000000000..17a596819 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AI_단가연결.md @@ -0,0 +1,33 @@ +# 조사 AI — 거리이정표 운반 행 · 단가연결 (사용자 159 · 160번) + +브레인 직접 조사 · 2026-10-11 + +## 1. 지금 모습 + +- B08 총괄자재표 = 고정 줄 `TOTAL_ROWS`(`B08_Quantity_Material_Calc.py:104-126`) · 칸 key · group · spec · unit · qty · amount(할증 · 끝수 반영) + - 사본(구조물조합 → 일위대가) 자재는 글자 맞춤으로 고정 줄에 붙음 · 안 맞으면 unmatched 줄 +- B09 설계내역서 운반공(`SECTIONS` "3") = 고정 6줄 `HAUL`(`B09_Estimation_Bill_Items.py:50-57`) — 중기운반 · 기계운반 · 모래 · 자갈 · 시멘트 · 철근 운반 · 수량 = 총괄 amount · ref 없음 · 「운반거리 원천 없음」 +- 거리이정표 값은 B09 어디서도 안 읽음 +- 연결 고르기 = `B09_Estimation_UI_Tabs_Bill_Link.ts` 메뉴 다섯(일위대가 호표 · 단가산출 호표 · 구조물 조합 · 일위대가 사전조합 · 단가산출) + - 단가산출 = `openLogicPicker` → `askInputs`(옵션 값 입력 창 `openLaborInput`) — 160번 「옵션 추가 모달」은 이미 있는 길 + - `openLogicPicker(done, lib)` = 로직 + 서고 사전조합을 한 목록(검색 · 구분 · 상세구분 · 층 단추)으로 이미 보임 — 새 모달의 바탕 + - 구조물 조합 · 사전조합 = `importLib` → 서버 사본(`POST …/estimation/library`) → `{tab: dg, key, lib}` +- 로직 연결 단가는 `Sangeun.hand_sources(user)`가 user.bill 덮개 줄에서만 읽음 · 사본 정리 `Bill_Library.used_folders(user)` · 단가 수요 `hand_resource_keys(user)`도 같음 +- 운반 로직 중 km 입력 = `GF000284` 덤프운반 「운반거리」 하나 · 그 밖은 사이클시간 · 포장/비포장 입력 없음 + +## 2. 정함(브레인 · 사용자 「알아서」) + +1. **운반 행 = 총괄자재 묶음** — 총괄 줄 중 수량 있는 것 · 같은 묶음(이형철근 D13~D25 · 레미콘 셋 · 깬돌 둘)은 한 행 · 골재는 모래 · 자갈 · 막자갈 · 혼합석 따로 · 장비 두 행(중기 · 기계) 늘 · 옛 키 `haul:1~6` 은 그 묶음에 그대로(사용자 덮개 고아 없음) · 새 묶음 = `haul:<묶음 키>` +2. **거리이정표 자동 상자** — 서버 읽기 때 운반 행마다 거래처 상자가 없으면 넣음(거래처 · 주소 빈칸 · 선 없음 · 항목 = 자재 이름 · 저장은 [저장] 때) · 사용자 [요소 추가] 상자도 그대로 +3. **단가연결 저장 자리 = 거리이정표 행(정본)** — `route.ref`(dg · sg · logic 꼴 그대로) · B09 운반 줄 ref = 그 값(B09 에서 고친 덮개가 있으면 덮개가 이김) + - 로직 입력 「운반거리」가 있으면 B09 셈 때 거리이정표 합계 km 로 채움 + - B09 `hand_sources` · `used_folders` · `hand_resource_keys` 가 자동 줄 ref 도 읽게(서버만 · 화면 셈은 서버가 준 산근 그대로) +4. **단가연결 모달** — `openLogicPicker` 바탕 한 목록: 종류 고르기(전체 · 단가산출 · 일위대가 사전조합 · 구조물 조합 · 일위대가 호표 · 단가산출 호표) + 검색 + 구분 · 상세구분 + 층(마스터 · 회사 · 개인) · 단가산출 = 옵션 값 창(160번) · 사전조합 · 구조물조합 = 사본 들여옴 + - B09 설계내역서 우클릭 · 「연결 ▾」 칸 · 거리이정표 표 · 상자 = 같은 모달 · 이름 「연결」 → 「단가연결」 +5. **램프** — 거리이정표 표 항목 앞 · 상자 · 초록 = 단가 연결됨 · 노랑 = 안 됨(구조물 집계표 램프 꼴) + +## 3. 위험 + +- 운반 행 키 바뀜 → 옛 키 그대로 둬 막음 +- 사본 정리(prune)가 거리이정표 ref 사본을 지우지 않게 used_folders 에 자동 줄 ref 포함 +- 거울 짝 Calc_Bill(py · ts) 은 안 고침(줄 짜기 · ref 는 서버 inputs 로 감) From 5770cb4ce7ec228bfeb6d25178eb039246b82f00 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:09:11 +0900 Subject: [PATCH 023/151] =?UTF-8?q?feat(b08):=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=ED=95=A8=EC=88=98=20=E2=80=94=20=EC=B4=9D?= =?UTF-8?q?=EA=B4=84=EC=9E=90=EC=9E=AC=20=E2=86=92=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=ED=96=89=20=C2=B7=20B09=20=EC=9A=B4=EB=B0=98=EA=B3=B5=20?= =?UTF-8?q?=EC=A4=84=20=EA=B0=99=EC=9D=80=20=EC=9B=90=EC=B2=9C=20(251-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Haul.py | 103 ++++++++++++++++++++ B09_Estimation/B09_Estimation_Bill_Items.py | 27 ++--- resources/tester/test_251_1_haul_groups.py | 50 ++++++++++ 3 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Haul.py create mode 100644 resources/tester/test_251_1_haul_groups.py diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py new file mode 100644 index 000000000..b8ccc551f --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -0,0 +1,103 @@ +"""B08 운반 행(PLAN 251-1 · 사용자 159번) — 총괄자재표 → 운반 묶음. + +거리이정표 행 · B09 운반공 줄의 한 원천. +묶음 = 총괄 줄 중 수량 있는 것 · 같은 묶음(이형철근 · 레미콘 · 깬돌 · 파형강관 · +커플링밴드)은 한 행 · 골재(모래 · 자갈 · 막자갈 · 혼합석) · 그 밖 줄은 따로 · +채집석 공제 뺌 · 장비 두 행(중기 · 기계) 늘. +옛 B09 운반 6줄 키(`haul:1~6` · 중기 · 기계 · 모래 · 자갈 · 시멘트 · 철근)는 그 묶음에 그대로 — +사용자 설계내역서 덮개가 고아가 되지 않게 · 그 여섯은 수량이 없어도 늘 있음. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY + +#: 장비 — (묶음 키, 이름, 단위, 옛 번호) +EQUIP: list[tuple[str, str, str, int]] = [ + ("equip_heavy", "중기", "대", 1), + ("equip_machine", "기계", "대", 2), +] +#: 옛 B09 운반 줄 번호 — 묶음 키 → n(`haul:n`) +LEGACY = { + "equip_heavy": 1, + "equip_machine": 2, + "sand": 3, + "gravel": 4, + "cement": 5, + "rebar": 6, +} +#: 총괄 줄 키 머리 → (묶음 키, 이름) — 여러 규격 = 한 행 +PREFIX = [ + ("rebar_", "rebar", "철근"), + ("remicon_", "remicon", "레미콘"), + ("stone_buy_", "stone", "깬돌"), + ("pipe_", "pipe", "파형강관"), + ("coupling_", "coupling", "커플링밴드"), +] + + +#: 총괄 고정 줄 이름 · 단위(줄에 이름이 없어도 같은 이름 · 옛 여섯 늘 행) +NAMES = { + "sand": ("모래", "㎥"), + "gravel": ("자갈", "㎥"), + "rubble": ("막자갈", "㎥"), + "mixed_aggregate": ("혼합석", "㎥"), + "cement": ("시멘트", "대"), + "rebar": ("철근", "ton"), + "wire_mesh": ("와이어메쉬", "㎡"), + "sod": ("떼", "㎡"), + "revet_block": ("호안블럭", "㎡"), + "flume_400c": ("수로관", "본"), + "steel_grating": ("스틸그레이팅", "EA"), +} + + +def _group(row: dict[str, Any]) -> tuple[str, str]: + key = str(row.get("key") or "") + for head, gkey, name in PREFIX: + if key.startswith(head): + return gkey, name + if key in NAMES: + return key, NAMES[key][0] + return key, str(row.get("group") or row.get("spec") or key) # 맞춤 없는 사본 자재 = 자재 이름 + + +def bill_key(gkey: str) -> str: + """B09 운반 줄 키 — 옛 여섯은 `haul:n` 그대로 · 새 묶음 = `haul:<묶음 키>`.""" + return f"haul:{LEGACY[gkey]}" if gkey in LEGACY else f"haul:{gkey}" + + +def haul_groups(materials: dict[str, Any] | None) -> list[dict[str, Any]]: + """총괄자재표(`material.rows`) → 운반 행 [{key, name, unit, qty}]. + + 장비 둘 + 묶음(차례 = 총괄 줄 차례).""" + rows = ((materials or {}).get("material") or {}).get("rows") or [] + out: dict[str, dict[str, Any]] = { + g: {"key": g, "name": n, "unit": u, "qty": None} for g, n, u, _ in EQUIP + } + for row in rows: + if row.get("key") == DEDUCT_KEY: # 채집석 공제(뺄 수량) + continue + gkey, name = _group(row) + amount = row.get("amount") + has = isinstance(amount, (int, float)) and amount > 0 + slot = out.get(gkey) + if slot is None: + if not has and gkey not in LEGACY: + continue # 수량 없는 새 묶음 = 행 없음 + slot = out[gkey] = { + "key": gkey, + "name": name, + "unit": row.get("unit") or "", + "qty": None, + } + if has and (slot["unit"] in ("", row.get("unit"))): + slot["unit"] = row.get("unit") or slot["unit"] + slot["qty"] = round((slot["qty"] or 0) + float(amount), 6) + for gkey in LEGACY: # 옛 여섯 = 늘 + if gkey not in out: + name, unit = NAMES[gkey] + out[gkey] = {"key": gkey, "name": name, "unit": unit, "qty": None} + return list(out.values()) diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index 29891ac95..a3a3ac17d 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -14,6 +14,7 @@ from __future__ import annotations from typing import Any +from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups from B08_Quantity.B08_Quantity_Material_Calc import _structure_row WHY_EARTH = "조합 밖 토공 산근 — 연결 없음" @@ -46,16 +47,6 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [ STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"} SKIP_COLUMNS = ("pipe:cp|",) -#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None) -HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [ - ("중기운반", "대", None), - ("기계운반", "대", None), - ("모래운반", "㎥", ("sand",)), - ("자갈운반", "㎥", ("gravel",)), - ("시멘트운반", "대", ("cement",)), - ("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")), -] - def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool: group, kind, spec = want @@ -114,21 +105,17 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]: - rows = ((materials or {}).get("material") or {}).get("rows") or [] - amount = {row.get("key"): row.get("amount") for row in rows} + """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로).""" out = [] - for n, (name, unit, keys) in enumerate(HAUL, 1): - qty = None - if keys: - found = [amount[k] for k in keys if amount.get(k) is not None] - qty = round(sum(found), 6) if found else None + for g in haul_groups(materials): + qty = g["qty"] out.append( { - "key": f"haul:{n}", + "key": bill_key(g["key"]), "section": "3", - "name": name, + "name": f"{g['name']}운반", "spec": "", - "unit": unit, + "unit": g["unit"], "qty": qty, "note": "B08 총괄자재" if qty is not None else "", "ref": None, diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py new file mode 100644 index 000000000..127f09d49 --- /dev/null +++ b/resources/tester/test_251_1_haul_groups.py @@ -0,0 +1,50 @@ +"""PLAN 251-1 — 총괄자재표 → 운반 묶음(거리이정표 행 · B09 운반공 줄 한 원천).""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 + + +def _mats(rows): + return {"material": {"rows": rows}} + + +ROWS = [ + {"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16}, + {"key": "gravel", "group": "골재", "spec": "자갈", "unit": "㎥", "amount": None}, + {"key": "mixed_aggregate", "group": "골재", "spec": "혼합석", "unit": "㎥", "amount": 3.5}, + {"key": "remicon_25_24_15", "group": "레미콘", "spec": "25-24-15", "unit": "㎥", "amount": 2}, + {"key": "remicon_25_21_80", "group": "레미콘", "spec": "25-21-80", "unit": "㎥", "amount": 1}, + {"key": "rebar_d13", "group": "이형철근", "spec": "D13mm", "unit": "ton", "amount": 0.5}, + {"key": "rebar_d16", "group": "이형철근", "spec": "D16mm", "unit": "ton", "amount": 0.25}, + {"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": None}, + {"key": "pipe_600", "group": "파형강관", "spec": "Φ600mm", "unit": "m", "amount": 12}, + {"key": "picked_deduct", "group": "채집석 공제", "unit": "㎥", "amount": 4}, + {"key": "MT000777", "group": "앵커볼트", "spec": "M16", "unit": "EA", "amount": 8}, + {"key": "sod", "group": "떼", "unit": "㎡", "amount": 0}, +] + + +def test_묶음_장비_옛_여섯_늘_수량_없는_새_묶음_없음(): + got = {g["key"]: (g["name"], g["unit"], g["qty"]) for g in haul_groups(_mats(ROWS))} + assert list(got)[:2] == ["equip_heavy", "equip_machine"] # 장비 둘 맨 앞 + assert got["sand"] == ("모래", "㎥", 16) and got["gravel"] == ("자갈", "㎥", None) + assert got["mixed_aggregate"] == ("혼합석", "㎥", 3.5) + assert got["remicon"] == ("레미콘", "㎥", 3) and got["rebar"] == ("철근", "ton", 0.75) + assert got["cement"] == ("시멘트", "대", None) # 옛 여섯 = 수량 없어도 늘 + assert got["pipe"] == ("파형강관", "m", 12) and got["MT000777"] == ("앵커볼트", "EA", 8) + assert "picked_deduct" not in got and "sod" not in got # 공제 · 수량 0 새 묶음 = 행 없음 + + +def test_B09_운반_줄_옛_키_그대로_새_묶음_키(): + items = {i["key"]: i for i in haul_items(_mats(ROWS))} + assert [bill_key(k) for k in ("equip_heavy", "sand", "rebar")] == ["haul:1", "haul:3", "haul:6"] + assert items["haul:3"]["name"] == "모래운반" and items["haul:3"]["qty"] == 16 + assert items["haul:remicon"]["name"] == "레미콘운반" and items["haul:remicon"]["unit"] == "㎥" + assert all(i["section"] == "3" for i in items.values()) + assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items) From 0e6da95c95ca3612c3bf14d69d0eca4f33fb513a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:10:49 +0900 Subject: [PATCH 024/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=9D=BD=EA=B8=B0=20=3D=20?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=20=ED=96=89=EB=A7=88=EB=8B=A4=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EC=9E=90=EB=8F=99=20=C2=B7=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?haul=20=C2=B7=20ref=20(251-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Haul.py | 26 +++++++++++++++++ B08_Quantity/B08_Quantity_Milestone_Calc.ts | 4 +++ B08_Quantity/B08_Quantity_Router_Milestone.py | 16 ++++++++++- resources/tester/test_251_1_haul_groups.py | 28 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index b8ccc551f..93a01af50 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -101,3 +101,29 @@ def haul_groups(materials: dict[str, Any] | None) -> list[dict[str, Any]]: name, unit = NAMES[gkey] out[gkey] = {"key": gkey, "name": name, "unit": unit, "qty": None} return list(out.values()) + + +def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict[str, Any]: + """거리이정표 자료에 운반 행 상자 자동(251-2) — 묶음마다 거래처 상자가 없으면 넣음. + + 거래처 · 주소 빈칸 · 선 없음(linked false) · 항목 = 자재 이름 · + 저장은 [저장] 때(읽기만 합침).""" + have = {r.get("haul") for r in data.get("routes") or []} + routes = list(data.get("routes") or []) + for g in groups: + if g["key"] in have: + continue + routes.append( + { + "id": f"h-{g['key']}"[:40], + "name": "", + "address": "", + "via_base": True, + "linked": False, + "dir": None, + "haul": g["key"], + "legs": [{"road": None, "km": None}], + "rows": [{"item": g["name"], "dest": "start", "note": ""}], + } + ) + return {**data, "routes": routes} diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 8637bc66e..5c1bac853 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -87,6 +87,10 @@ export interface MilestoneRoute { linked?: boolean; /** 수기 입력(155) — 카카오 길찾기 안 함 · km 손 입력만 */ manual?: boolean; + /** 운반 묶음 키(251 · 총괄자재 · B09 운반 줄과 이음) — 없음 = 사용자 [요소 추가] 상자 */ + haul?: string | null; + /** 단가연결(251 · dg · sg · logic 꼴 · 정본) */ + ref?: Record | null; /** 업체 → 기준점(거침) · 시점 구간 */ legs: Leg[]; rows: MilestoneRow[]; diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 8e20c819c..e68cccdbc 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -49,9 +49,11 @@ from pydantic import BaseModel, Field, ValidationError, model_validator from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream +from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Material import material_sheets from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import ( @@ -101,6 +103,9 @@ class Place(BaseModel): class Route(Place): id: str = Field(pattern=ROUTE_ID) via_base: bool = True + # 251 운반 묶음 키(총괄자재 · B09 운반 줄과 이음) · 단가연결(dg · sg · logic 꼴 · 정본) + haul: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z0-9_-]+$") + ref: dict[str, Any] | None = None linked: bool = True # 155 False = 아직 선 안 이음(요소 추가 뒤) rows: list[Row] = Field(default_factory=list, max_length=50) @@ -239,10 +244,19 @@ def _route_id(route: str) -> str: raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error +async def _haul(project_id: UUID) -> list[dict[str, Any]]: + """운반 묶음(251) — 자재 집계 못 읽으면 빈 목록(거리이정표는 그대로).""" + try: + return haul_groups(await material_sheets(project_id)) + except Exception: # noqa: BLE001 — 자재 집계 없음 · 실패 = 자동 상자 없음 + return [] + + @router.get(PATH) async def milestone_read(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - return await asyncio.to_thread(lambda: payload(root, read_data(root))) + groups = await _haul(project_id) + return await asyncio.to_thread(lambda: payload(root, with_haul_routes(read_data(root), groups))) @router.put(PATH) diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py index 127f09d49..76d258c26 100644 --- a/resources/tester/test_251_1_haul_groups.py +++ b/resources/tester/test_251_1_haul_groups.py @@ -48,3 +48,31 @@ def test_B09_운반_줄_옛_키_그대로_새_묶음_키(): assert items["haul:remicon"]["name"] == "레미콘운반" and items["haul:remicon"]["unit"] == "㎥" assert all(i["section"] == "3" for i in items.values()) assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items) + + +def test_251_2_거리이정표_운반_상자_자동(): + from B08_Quantity.B08_Quantity_Haul import with_haul_routes + + groups = haul_groups(_mats(ROWS)) + data = {"site": {}, "base": {"enabled": False}, "routes": [ + {"id": "h-sand", "name": "골재상", "haul": "sand", "rows": [{"item": "모래"}]}, + {"id": "u1", "name": "내 상자", "rows": []}, + ]} # fmt: skip + out = with_haul_routes(data, groups) + by = {r["id"]: r for r in out["routes"]} + assert by["h-sand"]["name"] == "골재상" # 있는 상자 그대로 + assert "u1" in by # 사용자 상자 그대로 + new = by["h-remicon"] + assert (new["name"], new["address"], new["linked"], new["haul"]) == ("", "", False, "remicon") + assert new["rows"] == [{"item": "레미콘", "dest": "start", "note": ""}] + assert len([r for r in out["routes"] if r.get("haul")]) == len(groups) + assert data["routes"][0] is out["routes"][0] and len(data["routes"]) == 2 # 원본 안 바뀜 + + +def test_251_2_서버_모델_읽기_길(): + from B08_Quantity.B08_Quantity_Router_Milestone import Route + + r = Route(id="h-sand", haul="sand", ref={"tab": "logic", "code": "GF000284"}) + assert r.haul == "sand" and r.ref == {"tab": "logic", "code": "GF000284"} + src = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Milestone.py").read_text(encoding="utf-8") + assert "with_haul_routes(read_data(root), groups)" in src From 8e7a795abc465700f23428efa5aa32e63ea20cc4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:18:31 +0900 Subject: [PATCH 025/151] =?UTF-8?q?feat(b09):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=95=98=EC=9C=84=20=EB=A9=94=EB=89=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=84=AF=20=E2=86=92=20=ED=95=9C=20=EC=B0=BD(=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EA=B2=80?= =?UTF-8?q?=EC=83=89)=20=C2=B7=20=EC=9D=B4=EB=A6=84=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(251-3=20=C2=B7=20251-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 8 +- .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 7 +- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 158 +++++++++--------- .../M02_MasterTemplete_Labor_Pick.ts | 45 ++++- resources/tester/test_166_2_b09_bill_hand.py | 4 +- resources/tester/test_182_b09_bill_level.py | 32 ++-- resources/tester/test_184_3_b09_bill_menu.py | 12 +- .../tester/test_204_2_b09_bill_library.py | 30 ++-- .../tester/test_246_1_b09_bill_menu_simple.py | 7 +- .../tester/test_246_2_context_submenu_side.py | 2 +- 10 files changed, 178 insertions(+), 127 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index fc8e13020..dd0322a7b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -8,7 +8,7 @@ * 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값 * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` - * 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 + * 「단가연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 * ========================================================================== */ @@ -37,7 +37,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { const rows: DataRow[] = raw.map((r: Row) => ({ ...r, name: r.no ? `${r.no} ${r.name}` : r.name, - connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "", + connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "", })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ @@ -57,14 +57,14 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), - text("connect", "연결", 96), + text("connect", "단가연결", 96), text("note", "비고", 200), ], rows, total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「단가연결 ▾」로 일위대가 · 단가산출 연결" + (direct.blank ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` : ""), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index f036c2206..35a5dcb81 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -252,8 +252,8 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent)); }); if (linkable(row)) { - const [picks, more] = linkFor(ctx, rows, i); - parts[1].push({ label: "연결", children: picks }, ...more); + const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯) + parts[1].push(...picks, ...more); } // 옮김 · 레벨 — 같은 부모 형제와 자손째 if (!fixed(key)) { @@ -355,7 +355,8 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void const box = lastCell?.getBoundingClientRect(); if (!shared || !box || !linkable(rows[i])) return; const [picks, more] = linkFor(ctx, rows, i); - shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); + if (!more.length) return picks[0][1](); // 251-6 아직 연결 없음 = 단가연결 모달 바로 + shared.open(box.left, box.bottom, [...picks, "-", ...more]); } /** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 9152d1120..c8293beb6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -4,6 +4,8 @@ * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · * 연결 풀기. + * 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 · + * 단가산출 = 옵션 값 창(`askInputs` · 160) * * 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름) * 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 · @@ -12,9 +14,8 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; -import { createFindList } from "@ui/ui_template_find_list"; -import { openItemPicker } from "@ui/ui_template_item_picker"; -import { openModal } from "@ui/ui_template_modal"; +import { loadRows } from "@ui/ui_template_item_picker"; +import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; @@ -24,47 +25,14 @@ import type { TabContext } from "./B09_Estimation_UI_Tabs"; /** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */ export type ApplyRef = (ref: Row | null, from?: Row) => void; export type Leaf = [string, () => void]; - -/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */ -function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void { - const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || []; - if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error"); - const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`); - openModal({ - title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기", - closeLabel: "닫기", - mount: (body, close) => { - const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" }); - const find = (): void => { - const q = finder.search.value.trim().toLowerCase(); - const found = listing.filter( - (r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q), - ); - finder.hint.textContent = `${found.length} / ${listing.length}`; - finder.setRows( - found.map((r) => ({ - key: String(r.key), - tag: label(r), - name: String(r.name || ""), - sub: String(r.spec || ""), - unit: String(r.unit || ""), - onPick: () => { - close(); - apply({ tab, key: r.key }, r); - }, - })), - ); - }; - finder.search.addEventListener("input", find); - find(); - body.append(finder.root); - requestAnimationFrame(() => finder.search.focus()); - }, - }); -} +const LAYER_TEXT: Record = { + system: "마스터", + company: "회사", + personal: "개인", +}; /** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */ -function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void { +export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void { fetchLogicInputs([code]).then( (got) => { const logic = got.로직[0] ?? null; @@ -94,10 +62,10 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void /** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만 * 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */ async function importLib( - ctx: TabContext, - apply: ApplyRef, + o: { apply: ApplyRef; onPayload?: (payload: Row) => void }, pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string }, ): Promise { + const apply = o.apply; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return; showToast("가져오는 중…", "info"); @@ -115,7 +83,7 @@ async function importLib( } if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); const { user: _user, ...rest } = got.payload as Row; - Object.assign(ctx.payload, rest); + o.onPayload?.(rest); const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); showToast( @@ -127,39 +95,79 @@ async function importLib( } } -/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */ -function pickCombo(ctx: TabContext, apply: ApplyRef): void { - void openItemPicker({ current: null, canCopy: false }).then((got) => { - if (got.action !== "apply") return; - const { layer, owner, key } = got.item; - void importLib(ctx, apply, { kind: "combo", layer, owner, key }); - }); -} - -/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */ -function pickPrecombo(ctx: TabContext, apply: ApplyRef): void { - openLogicPicker(() => undefined, { - layer: "personal", - owner: null, - libOnly: true, - title: "일위대가 사전조합 고르기", - pick: (ref) => - void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), - }); -} - -/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */ +/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 */ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { const more: Leaf[] = []; if (ref?.tab === "logic") more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]); - if (ref) more.push(["연결 풀기", () => apply(null)]); - const picks: Leaf[] = [ - ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], - ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], - ["구조물 조합", () => pickCombo(ctx, apply)], - ["일위대가 사전조합", () => pickPrecombo(ctx, apply)], - ["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))], + if (ref) more.push(["단가연결 풀기", () => apply(null)]); + const open: Leaf = [ + "단가연결", + () => + openPriceLink({ + apply, + hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, + onPayload: (p) => Object.assign(ctx.payload, p), + }), ]; - return [picks, more]; + return [[open], more]; +} + +/** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 · + * 개인 층 단추) · 호표(B09 · 있을 때) · 종류 고르기 + 검색 + 구분 · 상세구분 거름 · + * 단가산출 = 옵션 값 창(`askInputs` · 160) · 사전조합 · 구조물 조합 = 프로젝트 사본 들여옴 → `{tab: dg, key, lib}` */ +export interface PriceLinkOpts { + apply: ApplyRef; + /** B09 호표 목록(일위대가 · 단가산출) — 없으면 그 종류 없음(B08) */ + hopyo?: { dg: Row[]; sg: Row[] }; + /** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */ + onPayload?: (payload: Row) => void; +} + +export async function openPriceLink(o: PriceLinkOpts): Promise { + const hop = (tab: "dg" | "sg") => + ((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({ + kind: tab, + key: String(r.key), + tag: tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`, + name: String(r.name || ""), + sub: String(r.spec || ""), + unit: String(r.unit || ""), + onPick: () => o.apply({ tab, key: r.key }, r), + })); + // 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천) + const combos = (await loadRows().catch(() => [])).map((r) => ({ + kind: "combo", + key: `${r.layer}:${r.owner ?? ""}:${r.key}`, + tag: "구조물 조합", + name: itemName(r), + sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`, + unit: String(r.단위 || ""), + onPick: () => + void importLib(o, { + kind: "combo", + layer: r.layer ?? "system", + owner: r.owner ?? null, + key: r.key, + }), + })); + openLogicPicker((logic) => askInputs(o.apply, logic.키, logic.이름), { + layer: "personal", + owner: null, + title: "단가연결", + extra: [...combos, ...hop("dg"), ...hop("sg")], + kindFilter: [ + { value: "logic", text: "단가산출" }, + { value: "precombo", text: "일위대가 사전조합" }, + { value: "combo", text: "구조물 조합" }, + ...(o.hopyo + ? [ + { value: "dg", text: "일위대가 호표" }, + { value: "sg", text: "단가산출 호표" }, + ] + : []), + ], + pick: (ref) => + void importLib(o, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), + }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 310825c63..043ece45b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -3,6 +3,8 @@ * [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) · * 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} · * 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류) + * 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) + + * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -38,6 +40,18 @@ export interface LibraryPick { /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ libOnly?: boolean; title?: string; + /** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */ + extra?: Array<{ + kind: string; + key: string; + tag: string; + name: string; + sub: string; + unit: string; + onPick: () => void; + }>; + /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ + kindFilter?: Array<{ value: string; text: string }>; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -72,12 +86,21 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra let scopes: Scope[] = []; let scope: Scope | null = null; let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = []; + // 251 종류 고르기 — 값 "" = 전체 · logic · precombo · 덧붙인 줄 kind + const kindSel = lib?.kindFilter + ? createSelectField({ + options: [{ value: "", text: L("M01_All") }, ...lib.kindFilter], + compact: true, + label: "종류", + }) + : null; + const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k; let loading = false; let seq = 0; /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ const finder = createFindList({ placeholder: tc("Pick_Search"), - filters: [sub.root, detail.root], + filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root], extra: [layerBar], }); @@ -85,18 +108,26 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const q = finder.search.value.trim().toLowerCase(); const found = all.filter( (x) => + kindOk("logic") && (!sub.select.value || x.구분 === sub.select.value) && (!detail.select.value || x.상세구분 === detail.select.value) && matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1) ); const libFound = libRows.filter( (x) => + kindOk("precombo") && (!sub.select.value || x.row.구분 === sub.select.value) && (!detail.select.value || x.row.상세구분 === detail.select.value) && (!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)), ); - const total = all.length + libRows.length; - const shown = found.length + libFound.length; + const extra = (lib?.extra ?? []).filter( + (x) => + kindOk(x.kind) && + !sub.select.value && + (!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)), + ); + const total = all.length + libRows.length + (lib?.extra?.length ?? 0); + const shown = found.length + libFound.length + extra.length; finder.hint.textContent = loading ? tc("Loading") : shown @@ -132,6 +163,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra }, }; }), + ...extra.map((x) => ({ + ...x, + onPick: () => { + close(); + x.onPick(); + }, + })), ]); }; const loadLib = async (): Promise => { @@ -192,6 +230,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra find(); }); detail.select.addEventListener("change", find); + kindSel?.select.addEventListener("change", find); finder.search.addEventListener("input", find); layerBar.style.display = "none"; diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 8941dc397..436f12882 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -227,10 +227,10 @@ console.log(JSON.stringify(out)); out = json.loads(_node(src + body)) # 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로 assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"] - assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", + assert out["auto"] == ["항목 추가", "-", "단가연결", "-", "아래로 ↓", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑", + assert out["hand"] == ["항목 추가", "-", "단가연결", "단가연결 풀기", "-", "위로 ↑", "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip # 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) below = out["below"] diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 1ae488159..122959fd6 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -266,6 +266,13 @@ globalThis.openModal = (o) => { picked.title = o.title; o.mount({ append() {} }, () => (picked.closed = true)); }; +// 251-6 단가연결 = 한 모달(openLogicPicker 바탕) — 호표 줄 = lib.extra +globalThis.loadRows = async () => []; +globalThis.openLogicPicker = (done, lib) => { + picked.title = lib.title; + picked.kinds = lib.kindFilter.map((k) => k.text); + picked.rows = lib.extra; +}; let user = __USER__; const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {}, get user() { return user; }, setUser: (u) => (user = u) }; @@ -288,11 +295,11 @@ const out = { table.onCell(at("hand:a"), "connect"); table.onCell(at("hand:g1"), "connect"); table.onCell(at("earth:4"), "connect"); -table.onCell(at("hand:c"), "connect"); +table.onCell(at("hand:c"), "connect"); // 연결 없음 = 단가연결 창 바로(메뉴 없음) +await new Promise((r) => setTimeout(r, 0)); out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]); -opened[2].items[0][1](); // hand:c → 일위대가 호표 고르기 창 -picked.rows[0].onPick(); -out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)]; +picked.rows.find((r) => r.tag === "대가3").onPick(); +out.picked = [picked.title, picked.kinds, picked.rows.map((r) => r.tag)]; out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref; console.log(JSON.stringify(out)); """ @@ -318,17 +325,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): ] # fmt: skip 201-1 레벨 = 공용 표 level assert out["lv"] == ["name", 1, 3, 4, 3] assert out["edit"] == [True, False] # 묶음 줄 = 이름만 - picks = [ - "일위대가 호표", - "단가산출 호표", - "구조물 조합", - "일위대가 사전조합", - "단가산출", - ] # 204-2 · 246-1 이름 셋 - # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 - unlink = [10, 40, [*picks, "-", "연결 풀기"]] - assert out["menus"] == [unlink, unlink, [10, 40, picks]] - assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 + # 251-6 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄 = [단가연결 · 단가연결 풀기] · + # 연결 없는 줄 = 단가연결 창 바로(종류 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘) + unlink = [10, 40, ["단가연결", "-", "단가연결 풀기"]] + assert out["menus"] == [unlink, unlink] + kinds = ["단가산출", "일위대가 사전조합", "구조물 조합", "일위대가 호표", "단가산출 호표"] + assert out["picked"] == ["단가연결", kinds, ["대가3"]] assert out["ref"] == DG diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index e7e72e92b..b3daf1fba 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -123,15 +123,15 @@ def test_메뉴_모든_줄(): # 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음) assert out["sec:1"] == [*add, "-", "아래로 ↓"] # 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음) - assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-", + assert out["earth:4"] == [*add, "-", "단가연결", "단가연결 풀기", "-", "아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓", + assert out["earth:5"] == [*add, "-", "단가연결", "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip - assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←", + assert out["hand:a"] == [*add, "-", "단가연결", "단가연결 풀기", "-", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip # 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 · # 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도) @@ -174,7 +174,7 @@ def test_삭제_옮김_새_공종_자동값으로(): ("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"), ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"), ("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), - ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip + ("earth:4", "단가연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip out = _menu(user, runs=runs)["runs"] keys = lambda b: [k for k, _ in _order(b)] # noqa: E731 # 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘 @@ -215,7 +215,7 @@ def test_삭제_옮김_새_공종_자동값으로(): got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]} assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2" # 자동 줄 연결 풀기 = ref 덮개 null - assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None + assert {e["key"]: e for e in out["earth:4|단가연결 풀기"]["rows"]}["earth:4"]["ref"] is None # 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) got = out["fee|항목 추가"]["rows"] assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2") @@ -270,7 +270,7 @@ def test_표_칸_강조_번호_연결_칸(): ) out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body)) assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no - assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""] + assert out["connect"] == ["연결됨 ▾", "단가연결 ▾", "대가3 ▾", "", "", ""] assert out["cls"] == ["is-edited", None, None] assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]" assert out["edit"] == [True, False, True, True, False] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index 4da59ffb2..71cd98c8c 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -188,10 +188,11 @@ globalThis.showToast = (m, k) => toasts.push([k, m]); globalThis.API_BASE_URL = "/api"; globalThis.CURRENT_PROJECT_ID_KEY = "pid"; globalThis.localStorage = { getItem: () => "P1" }; -globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]); - lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); }; -globalThis.openItemPicker = async () => ({ action: "apply", - item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } }); +let picker = null; // 251-6 단가연결 = 한 창(openLogicPicker · 사전조합 + 덧붙인 줄 구조물 조합) +globalThis.openLogicPicker = (done, lib) => { picker = lib; + sent.push(["pre", lib.layer, lib.kindFilter.map((k) => k.value), lib.title]); }; +globalThis.loadRows = async () => [{ key: "SS000011", layer: "system", owner: null, 단위: "m" }]; +globalThis.itemName = () => "관매설"; const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new", payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } }, sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } }; @@ -202,9 +203,12 @@ const applied = []; const apply = (ref, from) => applied.push([ref, from && from.name]); const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; -picks.find((x) => x[0] === "일위대가 사전조합")[1](); +picks[0][1](); await new Promise((r) => setTimeout(r, 0)); -picks.find((x) => x[0] === "구조물 조합")[1](); +out.combo = picker.extra.filter((x) => x.kind === "combo").map((x) => [x.tag, x.name, x.sub]); +picker.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); +await new Promise((r) => setTimeout(r, 0)); +picker.extra.find((x) => x.kind === "combo").onPick(); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); @@ -223,16 +227,12 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) - assert out["picks"] == [ - "일위대가 호표", - "단가산출 호표", - "구조물 조합", - "일위대가 사전조합", - "단가산출", - ] # 246-1 이름 셋(서고 · 층 · M01 글 없음) - assert out["more"] == ["연결 풀기"] + assert out["picks"] == ["단가연결"] # 251-6 고르기 다섯 = 단가연결 창 하나 + assert out["more"] == ["단가연결 풀기"] pre, (url, body) = out["sent"][0], out["sent"][1] - assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 + kinds = ["logic", "precombo", "combo", "dg", "sg"] + assert pre == ["pre", "personal", kinds, "단가연결"] # 층 셋 단추 · 종류 고르기 + assert out["combo"] == [["구조물 조합", "관매설", "마스터 · SS000011"]] assert url == "/api/projects/P1/estimation/library" assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py index 7a76c6545..450d2dd66 100644 --- a/resources/tester/test_246_1_b09_bill_menu_simple.py +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -31,11 +31,12 @@ def test_더함_하나_줄_삭제_연결_이름_셋(): assert not set(OLD_ADDS) & set(out[key]) and "줄 지우기" not in out[key] assert "줄 삭제" in out["earth:4"] and "줄 삭제" in out["hand:a"] link = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") - picks = link.split("const picks: Leaf[] = [")[1].split("];")[0] + # 251-6 고르기 다섯 = 단가연결 모달 하나 · 종류 이름 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘 + kinds = link.split("kindFilter: [")[1].split("],\n pick")[0] for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]: - assert f'["{name}",' in picks + assert f'text: "{name}"' in kinds for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]: - assert word not in picks + assert word not in kinds toasts = [part.split(",")[0] for part in link.split("showToast(")[1:]] assert toasts and not any("서고" in t for t in toasts) # 알림 글 쉬운 말 bill = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") diff --git a/resources/tester/test_246_2_context_submenu_side.py b/resources/tester/test_246_2_context_submenu_side.py index 3c09cc09a..f0bdc831c 100644 --- a/resources/tester/test_246_2_context_submenu_side.py +++ b/resources/tester/test_246_2_context_submenu_side.py @@ -165,4 +165,4 @@ def test_옛_아코디언_없음_공용_꼴(): assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css # B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로) edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8") - assert 'parts[1].push({ label: "연결", children: picks }, ...more);' in edit + assert "parts[1].push(...picks, ...more);" in edit # 251-6 단가연결 = 모달 하나(하위 메뉴 없음) From 433d6d204b2646a17a1a920c18fd64effbb0a9d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:23:01 +0900 Subject: [PATCH 026/151] =?UTF-8?q?feat(b09):=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EC=A4=84=20=EB=8B=A8=EA=B0=80=EC=97=B0=EA=B2=B0=20=3D=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EC=97=B0=EA=B2=B0=20=EC=A4=84=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=8C=20=ED=95=9C=20=EA=B3=B3(ref=5Frows)=20(251-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B09_Estimation/B09_Estimation_Bill_Items.py | 16 +++++++--- B09_Estimation/B09_Estimation_Bill_Library.py | 11 +++---- B09_Estimation/B09_Estimation_Calc_Bill.py | 2 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 2 +- B09_Estimation/B09_Estimation_Router.py | 29 ++++++++++++++--- B09_Estimation/B09_Estimation_Sangeun.py | 3 +- B09_Estimation/B09_Estimation_Store.py | 15 +++++++++ resources/tester/test_251_1_haul_groups.py | 31 +++++++++++++++++++ 8 files changed, 89 insertions(+), 20 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index a3a3ac17d..ec581bf7c 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -20,6 +20,7 @@ from B08_Quantity.B08_Quantity_Material_Calc import _structure_row WHY_EARTH = "조합 밖 토공 산근 — 연결 없음" WHY_READY = "준비공 대가 — 연결 없음" WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)" +WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)" WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)" #: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None) @@ -104,11 +105,16 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: return out -def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]: - """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로).""" +def haul_items( + materials: dict[str, Any] | None, refs: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) · + 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김).""" out = [] for g in haul_groups(materials): qty = g["qty"] + ref = (refs or {}).get(bill_key(g["key"])) + logic = (ref or {}).get("tab") == "logic" out.append( { "key": bill_key(g["key"]), @@ -118,8 +124,8 @@ def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]: "unit": g["unit"], "qty": qty, "note": "B08 총괄자재" if qty is not None else "", - "ref": None, - "why": WHY_HAUL, + "ref": ref, + "why": WHY_LOGIC if logic else WHY_HAUL, } ) return out @@ -130,7 +136,7 @@ def bill_items(b08: dict[str, Any]) -> dict[str, Any]: "items": [ *earth_items(b08.get("earthwork")), *structure_items(b08.get("summary")), - *haul_items(b08.get("materials")), + *haul_items(b08.get("materials"), b08.get("haul_refs")), ] } diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index b1c1a16c6..9e4b82ff8 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any from B09_Estimation.B09_Estimation_Store import folder as store_folder +from B09_Estimation.B09_Estimation_Store import ref_rows from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of @@ -110,15 +111,11 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: def used_folders(user: dict[str, Any]) -> set[str]: - """설계내역서 덮개 줄이 잇는 서고 사본 폴더.""" - rows = ((user or {}).get("bill") or {}).get("rows") or [] + """설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더.""" return { str(ref["lib"]) - for row in rows - if isinstance(row, dict) - and isinstance(ref := row.get("ref"), dict) - and ref.get("tab") == "dg" - and ref.get("lib") + for row in ref_rows(user) + if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib") } diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 30b69f9c4..2e71e0ea0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -416,7 +416,7 @@ def compute_bill( if price_of is not None and item.get("owners"): parts = split_by_block(item, price_of) else: - parts = [(item, ref_price(item.get("ref"), books, hopyo))] + parts = [(item, ref_price(item.get("ref"), books, hopyo, item["key"]))] for part, prices in parts: items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo))) for item in _material_items(inputs, base["compare"]): diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 3a5702b8e..215c7dee6 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -386,7 +386,7 @@ export function computeBill( const parts: Array<[Row, Row | null]> = priceOf !== null && item.owners && Object.keys(item.owners).length ? splitByBlock(item, priceOf) - : [[item, refPrice(item.ref ?? null, book, hopyo)]]; + : [[item, refPrice(item.ref ?? null, book, hopyo, item.key)]]; for (const [part, prices] of parts) items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo))); } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 62ccb65e8..5af0b89f7 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -27,9 +27,11 @@ from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException from pydantic import BaseModel +from B08_Quantity.B08_Quantity_Haul import bill_key from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun @@ -103,10 +105,25 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t return sheets, compute_summary(sheets, hopyo_lines(sheets)) +def haul_refs(root: Path) -> dict[str, Any]: + """거리이정표 운반 상자 단가연결(251-5) → `{설계내역서 줄 키: ref}` — 못 읽으면 빈 묶음.""" + try: + routes = read_milestone(root).get("routes") or [] + except Exception: # noqa: BLE001 — 거리이정표 없음 = 연결 없음 + return {} + return {bill_key(r["haul"]): r["ref"] for r in routes if r.get("haul") and r.get("ref")} + + +def calc_user(root: Path) -> dict[str, Any]: + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결(화면엔 안 보냄).""" + return {**store.read_user(root), "haul_refs": haul_refs(root)} + + async def _demand(root: Path) -> list[str]: - """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2).""" + """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) + + 거리이정표 운반 연결(251-5).""" keys = await asyncio.to_thread(linked_resource_keys, root) - user = await asyncio.to_thread(store.read_user, root) + user = await asyncio.to_thread(calc_user, root) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) return [*keys, *(k for k in hand if k not in keys)] @@ -121,11 +138,12 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: ) book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) - inputs["bill"] = bill_items(b08) + inputs["bill"] = bill_items({**b08, "haul_refs": await asyncio.to_thread(haul_refs, root)}) inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) - sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user) + calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} + sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) return { "inputs": inputs, "user": user, @@ -151,7 +169,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]: async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) user = await asyncio.to_thread(store.write_user, root, body.get("user")) - await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본 + calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} + await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index cf25e1acf..3b7f74dd8 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -38,6 +38,7 @@ from pathlib import Path from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total +from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -477,7 +478,7 @@ LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PART def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]: """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)].""" out = [] - for row in ((user or {}).get("bill") or {}).get("rows") or []: + for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함 ref = row.get("ref") or {} if ref.get("tab") != "logic" or not ref.get("code"): continue diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 0f1c5862a..bd839b659 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -94,6 +94,21 @@ def _text(value: Any) -> str: return str(value)[:200] if isinstance(value, (str, int, float)) else "" +def ref_rows(user: dict[str, Any] | None) -> list[dict[str, Any]]: + """연결 있는 줄 모음 — 설계내역서 덮개 줄 + 거리이정표 운반 연결 + (251-5 · `haul_refs{줄 키: ref}` · 서버 셈 때만 붙임) · + 같은 줄 키에 사용자 연결 덮개가 있으면 그것만.""" + rows = [r for r in ((user or {}).get("bill") or {}).get("rows") or [] if isinstance(r, dict)] + covered = {r.get("key") for r in rows if "ref" in r} + haul = (user or {}).get("haul_refs") or {} + extra = [ + {"key": key, "name": "", "ref": ref} + for key, ref in haul.items() + if key not in covered and isinstance(ref, dict) + ] + return [*rows, *extra] + + def _clean_ref(ref: Any) -> dict[str, Any] | None: """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) · dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움).""" diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py index 76d258c26..4d97f0073 100644 --- a/resources/tester/test_251_1_haul_groups.py +++ b/resources/tester/test_251_1_haul_groups.py @@ -76,3 +76,34 @@ def test_251_2_서버_모델_읽기_길(): assert r.haul == "sand" and r.ref == {"tab": "logic", "code": "GF000284"} src = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Milestone.py").read_text(encoding="utf-8") assert "with_haul_routes(read_data(root), groups)" in src + + +def test_251_5_B09_운반_줄_연결_거리이정표(): + from B09_Estimation.B09_Estimation_Bill_Library import used_folders + from B09_Estimation.B09_Estimation_Sangeun import hand_sources + from B09_Estimation.B09_Estimation_Store import ref_rows + + logic = {"tab": "logic", "code": "GF000284", "name": "덤프 운반", "given": {"L": 5}} + lib = {"tab": "dg", "key": "DG:x", "lib": "precombo@system@-@SC-1"} + items = {i["key"]: i for i in haul_items(_mats(ROWS), {"haul:3": logic, "haul:remicon": lib})} + assert items["haul:3"]["ref"] == logic and items["haul:3"]["why"].startswith("새 산근") + assert items["haul:remicon"]["ref"] == lib and items["haul:6"]["ref"] is None + # 셈 · 수요 · 사본 정리 = 설계내역서 덮개 + 운반 연결 · 같은 줄 덮개 연결이 이김 + user = {"bill": {"v": 2, "rows": [{"key": "haul:remicon", "ref": None}]}, + "haul_refs": {"haul:3": logic, "haul:remicon": lib}} # fmt: skip + assert [r["key"] for r in ref_rows(user)] == ["haul:remicon", "haul:3"] + assert [k for k, _u, _s in hand_sources(user)] == ["haul:3"] + assert used_folders(user) == set() # 덮개가 연결 풀었음 = 사본 안 지킴 + assert used_folders({"haul_refs": {"haul:remicon": lib}}) == {"precombo@system@-@SC-1"} + + +def test_251_5_자동_줄_단가_줄_키_짝(): + from B09_Estimation.B09_Estimation_Calc_Bill import ref_price + + books = {"sg_list": [{"key": "SG:9", "no": 9, "sum": 100}], "sg_hand": {"haul:3": "SG:9"}} + got = ref_price({"tab": "logic", "code": "GF000284"}, books, {}, "haul:3") + assert got and got["key"] == "SG:9" and got["no"] == "산근 9호표" + py = (ROOT / "B09_Estimation" / "B09_Estimation_Calc_Bill.py").read_text(encoding="utf-8") + ts = (ROOT / "B09_Estimation" / "B09_Estimation_Calc_Bill.ts").read_text(encoding="utf-8") + assert 'ref_price(item.get("ref"), books, hopyo, item["key"])' in py + assert "refPrice(item.ref ?? null, book, hopyo, item.key)" in ts From 8eb389eb399e51c6817e46f50fea50643887d462 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:29:32 +0900 Subject: [PATCH 027/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EB=8B=A8=EA=B0=80=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=9E=A8=ED=94=84=20=C2=B7=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=8B=A8=EA=B0=80=EC=97=B0=EA=B2=B0=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=ED=8B=80=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EB=A7=9E=EC=B6=A4=20(251-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_UI_Milestone.css | 16 +++++++ B08_Quantity/B08_Quantity_UI_Milestone.ts | 44 ++++++++++++++++++- .../B08_Quantity_UI_Milestone_Diagram.ts | 7 ++- .../B08_Quantity_UI_Milestone_Route.ts | 3 +- resources/tester/test_251_1_haul_groups.py | 35 +++++++++++++++ 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 2a210aa61..fe859ca05 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -268,3 +268,19 @@ .b08-milestone__route-addr { color: var(--color-text-secondary); } + +/* 단가연결 램프(251-4) — 항목 칸 앞 · 노랑 = 없음 · 초록 = 연결 */ +[data-b08-milestone="table"] td.b08-milestone__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + +[data-b08-milestone="table"] td.b08-milestone__lamp.is-linked::before { + background: var(--color-success); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 05058419a..148f27c34 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -9,11 +9,16 @@ * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` + * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · + * [단가연결 풀기] — 창 = B09 설계내역서와 같은 `openPriceLink` · 경로 `ref` · B09 운반공 줄이 받음 * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; +import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { @@ -35,6 +40,18 @@ import { const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; const ROAD = ["도로 구분 (km)"]; const SUM = ["계 (km)"]; +/** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */ +const LAMP = "b08-milestone__lamp"; + +/** 램프 호버 글 — 연결된 것 */ +export function refText(ref: Record | null | undefined): string { + if (!ref) return "단가연결 없음 — 줄 우클릭 [단가연결]"; + const what = + ref.tab === "logic" + ? `단가산출 ${String(ref.code ?? "")} ${String(ref.name ?? "")}` + : `일위대가 ${String(ref.name ?? ref.key ?? "")}`; + return `단가연결 — ${what.trim()}`; +} /** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ @@ -53,7 +70,7 @@ export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColu { key: "un", label: "비포장", group: ROAD, ...KM }, ]; return [ - { key: "item", label: "항목", width: 120 }, + { key: "item", label: "항목", width: 134 }, { key: "company", label: "거래처 이름", width: 120 }, { key: "dest", label: "목적지", width: 72 }, ...roads, @@ -84,6 +101,8 @@ export function addRoute(projectId: string, init: Partial = {}): function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; + const routeAt = (i: number) => + liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route); const makeTable = (columns: DataColumn[]) => { const t = createDataTable({ columns, @@ -91,6 +110,7 @@ function tableView(projectId: string): MilestoneView { caption: "거리이정표", empty: "", editOnDouble: true, + cellClass: (i, key) => (key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP), editable: (_i, key) => key === "item" || key === "company" || key === "note", onEdit: (i, key, text) => { const at = keys[i]; @@ -116,6 +136,26 @@ function tableView(projectId: string): MilestoneView { }); trackCells(t.root); t.root.dataset.b08Milestone = "table"; + attachHoverInfo( + t.root, + (cell) => refText(routeAt(Number(cell.closest("tr")?.dataset.i))?.ref), + `td.${LAMP}`, + ); + t.root.addEventListener("contextmenu", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + const route = tr ? routeAt(Number(tr.dataset.i)) : undefined; + if (!route) return; + event.preventDefault(); + const link = (ref: Row | null, from?: Row): void => + editRoute(projectId, route.id, (_, r) => { + // 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음 + r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" }; + }); + pickAtCell([ + ["단가연결", () => void openPriceLink({ apply: link })], + ...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []), + ]); + }); return t; }; let mode = "false:false"; @@ -177,7 +217,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { }; tabs.replaceChildren( tab("table", "거리이정표"), - ...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)), + ...routes.map((r, i) => tab(r.id, r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`)), ); }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index dc44b57f7..e7595d8a1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -421,12 +421,15 @@ export function createDiagram( }); }; refresh(); - // 틀 크기가 바뀌면(창 · 패널 접기) 그 비율로 다시 그림 + // 틀 크기가 바뀌면(창 · 패널 접기 · 높이 = 58vh) 그 비율로 다시 그림 — 그림이 틀보다 크면 아래가 잘림(251) let lastW = 0; + let lastH = 0; new ResizeObserver(() => { const w = host.clientWidth; - if (w && Math.abs(w - lastW) > 1) { + const h = host.clientHeight; + if (w && (Math.abs(w - lastW) > 1 || Math.abs(h - lastH) > 1)) { lastW = w; + lastH = h; refresh(); } }).observe(host); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 606fa3c38..d4b9c85b6 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -34,7 +34,8 @@ let lastCell: Element | null = null; /** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */ export function trackCells(root: HTMLElement): void { - root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true); + for (const type of ["click", "contextmenu"]) + root.addEventListener(type, (e) => (lastCell = (e.target as Element).closest("td")), true); } export function pickAtCell(items: MapContextMenuItem[]): void { diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py index 4d97f0073..3251d7a59 100644 --- a/resources/tester/test_251_1_haul_groups.py +++ b/resources/tester/test_251_1_haul_groups.py @@ -107,3 +107,38 @@ def test_251_5_자동_줄_단가_줄_키_짝(): ts = (ROOT / "B09_Estimation" / "B09_Estimation_Calc_Bill.ts").read_text(encoding="utf-8") assert 'ref_price(item.get("ref"), books, hopyo, item["key"])' in py assert "refPrice(item.ref ?? null, book, hopyo, item.key)" in ts + + +def test_251_4_거리이정표_램프_우클릭_단가연결(): + import json + import subprocess + + src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") + fn = ( + "export function refText" + + src.split("export function refText")[1].split("\n}\n")[0] + + "\n}\n" + ) + js = ( + fn + + """ +console.log(JSON.stringify([refText(null), + refText({ tab: "logic", code: "GF000134", name: "골재운반 모래" }), + refText({ tab: "dg", key: "DG:x", name: "회사 조합" })])); +""" + ) + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout) == [ + "단가연결 없음 — 줄 우클릭 [단가연결]", + "단가연결 — 단가산출 GF000134 골재운반 모래", + "단가연결 — 일위대가 회사 조합", + ] + # 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것 + assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src + assert "openPriceLink({ apply: link })" in src and '"단가연결 풀기"' in src + diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8") + assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음) From f598a9a53061763f42481696903cb03b29db6342 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:30:53 +0900 Subject: [PATCH 028/151] =?UTF-8?q?chore(plan):=2010-11=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20161?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index c9ed5f4ea..3e02a3e5c 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -189,3 +189,4 @@ 158. 자동정렬의 기준이 뭐임? 혹시 각각 요소들의 위치를 나타내는건가? (질문) 159. (원문) 1. 자동정렬 명칭을 방위배치로 변경(방위 배치 안보이고 테이블이 브라우져 폭을 다사용하게 반영 안됨) 2. 이제 연결작업을 할 생각임. 요소추가 버튼으로 생성할 수 있어야하지만 기본적으로 프로젝트의 사용하고 있는 구조물조합의 일위대가의 자재목록을 하나의 행으로 구성되어야함. - 초기에는 거래처와 주소가 지정되지 않은 컨테이너를 다이어그램에 배치해주고 사용자가 컨테이너의 모달로 접근하여 정보입력하게 됨. - 이후 선을 연결하여 정보 연결을 마무리함. 3. 이후 사용자는 단가산출 로직이나 마스터/회사/개인의 일위대가 사전조합을 연결한다. 이때 연결여부 표현을 위해 (구조물 집계표 측점 램프 그림) 항목 앞에 램프를 추가함. 4. 이 정보들은 모여서 B09의 운반공 하위의 항목으로 각 행들이 들어간다. 이때 B08에서 연결이 안되어 있더라도 설계내역서 행의 기능 중 연결 기능으로 단가산출이나 일위대가 사전조합을 연결할 수 있어야함. 5. 설계내역서/거리이정표의 마우스 우클릭의 기능 중 연결에서 여러정보들이 나오는데 이렇게 하지말고 모달창을 띄워서 보여주고 한번에 검색하거나 필터링하여 검색할수 있는 기능 부여. 이때 연결이라는 명칭보다는 단가연결 이라고 변경하여 표현 6. 연결은 중요한 문제니 너가 직접 조사 작업을 하여 계획서를 작성하고 작업을 이어서 해줘. 160. 추가로 단가연결을 할때는 구조물조합이나 일위대가 사전조합이라면 문제가 없겠지만 단가산출의 경우에는 연결할때 옵션값을 받아야하는 경우가 있을거야. 이때는 추가 모달창을 보여줘서 사용자가 선택할수 있게 반영해줘. 만약 내가 잘못알고 있는거라면 알아서 해주고 +161. (원문) 다음은 B09에 신규 메인탭인 공기를 넣어주고 하위에 공기집계와 공기산출 탭을 만들어줘. 이때 공기관련내용은 초안이니 B09 폴더 내부에 draft폴더를 만들어서 코드를 작업해줘. 나중에 마음에 안들면 그냥 draft폴더를 삭제할거야. 일단 참고문서는 resources\knowledge\original\실무문서\01.산불거창(거창북상병곡)\01.내역서\00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx 파일 중에 공기집계와 공기산출 탭을 참고하면 될거야. 여기서 중요한 점은 참고를 보고 내용을 넣는게 중요한게 아니라 로직을 분석하고 어떻게 계산하는지 확인하고 구현하는게 중요한거야. 다시말하지만 참고 항목이 있다고 테이블을 만드는게 아니야. 우리 프로그램에 맞춰 적용하는게 중요한거야. 그래서 길게 생각하고 작업해도 되니까. 꼼꼼히 분석해서 반영해줘. 이건 현재 작업내용을 이어서 진행하면 됨. 나 쉬러 갈테니까 작업완료하고 내일 아침에 내가 아티팩트를 만들어줘. 일단 이번 구현에 대해서는 방향성 결정이 필요한 내용이 있다면 너가 결정해서 아티팩트로 알려줘. From f87831533cd20232d61a0c8587b5791f9a6552dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:51:32 +0900 Subject: [PATCH 029/151] =?UTF-8?q?feat(b09):=20=EA=B3=B5=EA=B8=B0=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=E2=80=94=20=EB=A9=94=EC=9D=B8=20=ED=83=AD?= =?UTF-8?q?=20=EA=B3=B5=EA=B8=B0=20=C2=B7=20=EA=B3=B5=EA=B8=B0=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EA=B3=B5=EA=B8=B0=EC=82=B0=EC=B6=9C=20(dr?= =?UTF-8?q?aft=20=ED=8F=B4=EB=8D=94=20=C2=B7=20252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B09_Estimation/B09_Estimation_Router.py | 7 + B09_Estimation/B09_Estimation_Store.py | 10 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 10 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 7 + .../draft/B09_Draft_Schedule_Calc.py | 257 +++++++++++++++ .../draft/B09_Draft_Schedule_Calc.ts | 232 +++++++++++++ .../draft/B09_Draft_Schedule_Holidays.json | 19 ++ .../draft/B09_Draft_Schedule_Source.py | 136 ++++++++ B09_Estimation/draft/B09_Draft_Schedule_UI.ts | 306 ++++++++++++++++++ resources/tester/test_252_1_b09_schedule.py | 212 ++++++++++++ 10 files changed, 1193 insertions(+), 3 deletions(-) create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Calc.py create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Calc.ts create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Holidays.json create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Source.py create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_UI.ts create mode 100644 resources/tester/test_252_1_b09_schedule.py diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 5af0b89f7..05f8d0c3b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -50,6 +50,11 @@ from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer +try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이) + from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft +except ImportError: # pragma: no cover + schedule_draft = None + router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -144,6 +149,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: user = await asyncio.to_thread(store.read_user, root) calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) + if schedule_draft: + schedule_draft.attach(inputs, sheets, user) return { "inputs": inputs, "user": user, diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index bd839b659..07496606d 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -30,6 +30,11 @@ from urllib.parse import unquote from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS +try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이) + from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule +except ImportError: # pragma: no cover + clean_schedule = None + FOLDER = ("B09_Estimation", "v1") BOOK = "price_book.json" USER = "user_values.json" @@ -233,7 +238,10 @@ def clean_user(raw: Any) -> dict[str, Any]: if isinstance(rates, dict): estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} bill = clean_bill(raw.get("bill")) - return {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))): + out["schedule"] = schedule + return out def read_user(project_root: str | Path) -> dict[str, Any]: diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 78682d64f..445f1c234 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -34,6 +34,7 @@ import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { + DRAFT, rowIndex, TABS, type Link, @@ -63,6 +64,9 @@ import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) +import.meta.glob("./draft/*_UI.ts", { eager: true }); + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -136,7 +140,7 @@ export function computeSheets(payload: Row, user: Row): Row { books, ); const { gaps: daegaGaps, ...daega } = labor; - return { + const out: Row = { ...base, ...kept, ...daega, @@ -144,6 +148,8 @@ export function computeSheets(payload: Row, user: Row): Row { // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력 total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table), }; + for (const extend of DRAFT.extend) extend(payload, user, out); + return out; } export function renderB09Estimation(root: HTMLElement): void { @@ -186,7 +192,7 @@ export function renderB09Estimation(root: HTMLElement): void { bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); - const build = BUILDERS[tab]; + const build = BUILDERS[tab] ?? DRAFT.builders[tab]; if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); const ctx: TabContext = { sheets, payload, user, setUser }; const spec = build(ctx); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1b5a903b7..3dea31f92 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -120,6 +120,13 @@ export interface TabTable { export type TabBuild = (ctx: TabContext) => TabTable | string; +/** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — + * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */ +export const DRAFT: { + builders: Record; + extend: Array<(payload: Row, user: Row, sheets: Row) => void>; +} = { builders: {}, extend: [] }; + /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ key, diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py new file mode 100644 index 000000000..defcf5af0 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py @@ -0,0 +1,257 @@ +"""B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). + +짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`). +근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만: + + 공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) + 일작업량 = 장비 — 일 작업시간 × 대수 ÷ 단위당 주 장비 시간 + (= 일 작업시간 × Q · Q = 1 ÷ 단위당 시간) + 인력 — 투입 인원 ÷ 단위당 인(장비 없는 줄) + 입력 — 사용자 값(연결 없는 줄 · 덮어쓰기) + 단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위) + 반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0 + 소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0) + 공기집계 착공 연월부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) · + 법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) · + 중복 C = A × B ÷ 달력 · + 비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업 + 준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 · + 마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0) + 공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공(그 달 1일) + 공사기간 − 1 +""" + +from __future__ import annotations + +import datetime +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup + +HOURS = 8 +CREW = 10 +PREP_DOCS = 2 +PREP_SETUP = 2 +TIDY = 4 +#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 0(운반 = 다른 공종과 겹침) +SKIP_TOP = ("4", "G") +OVERLAP_TOP = ("3",) +MAX_MONTHS = 120 + + +def _top(section: Any) -> str: + return str(section or "").split(".")[0] + + +def _cfg(user: dict[str, Any], key: str, default: float) -> float: + value = num(user.get(key)) + return default if value is None or value < 0 else value + + +def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hours: float) -> dict: + qty = num(item.get("qty")) or 0.0 + rate = num(cfg.get("rate")) + if rate is None: + rate = 0.0 if _top(item.get("section")) in OVERLAP_TOP else 100.0 + daily_in = num(cfg.get("daily")) + machine = (unit or {}).get("machine") + labor = num((unit or {}).get("labor")) + out = { + "kind": "item", + "key": item["key"], + "name": item.get("name") or "", + "spec": item.get("spec") or "", + "unit": item.get("unit") or "", + "lv": item.get("level") or 1, + "qty": qty, + "rate": rate, + "basis": "", + "who": "", + "per": None, + "put": None, + "q": None, + "daily": None, + "daily_in": daily_in, + "days": None, + } + if daily_in is not None and daily_in > 0: + out.update(basis="입력", daily=daily_in) + need = qty * rate / 100 / daily_in + elif machine and (num(machine.get("hr")) or 0) > 0: + hr = num(machine["hr"]) + units = num(cfg.get("units")) or 1.0 + out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units) + out.update(q=1 / hr, daily=hours * units / hr) + need = qty * rate / 100 * hr / (hours * units) + elif labor is not None and labor > 0: + crew = num(cfg.get("crew")) or float(CREW) + out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor) + need = qty * rate / 100 * labor / crew + else: + out["basis"] = "연결 없음" + out["days"] = 0.0 if rate == 0 else None # 다 겹침 = 일작업량 없어도 0 + return out + out["days"] = roundup(need, 1) + return out + + +def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict: + """공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리.""" + hours = _cfg(user, "hours", HOURS) or HOURS + units = src.get("units") or {} + cfgs = user.get("rows") or {} + rows: list[dict[str, Any]] = [] + pending: list[list[Any]] = [] # [줄, 넣음] + for row in bill_rows: + level = row.get("level") or 1 + if row.get("kind") != "item": + pending = [p for p in pending if (p[0].get("level") or 1) < level] + pending.append([row, False]) + continue + key = str(row.get("key") or "") + qty = num(row.get("qty")) + if ( + _top(row.get("section")) in SKIP_TOP + or key.startswith("mat:") + or key == "fee" + or qty is None + or qty <= 0 + ): + continue + for p in pending: + if not p[1]: + p[1] = True + head = p[0] + rows.append( + { + "kind": "head", + "key": head["key"], + "name": head.get("name") or "", + "lv": head.get("level") or 1, + } # fmt: skip + ) + rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, hours)) + docs = _cfg(user, "prep_docs", PREP_DOCS) + setup = _cfg(user, "prep_setup", PREP_SETUP) + etc = _cfg(user, "etc", 0) + tidy = _cfg(user, "tidy", TIDY) + days = sum(r["days"] or 0 for r in rows if r["kind"] == "item") + subtotal = round_half(days + etc, 0) + missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None) + return { + "hours": hours, + "rows": rows, + "prep_docs": docs, + "prep_setup": setup, + "prep": docs + setup, + "etc": etc, + "subtotal": subtotal, + "tidy": tidy, + "total": round_half(docs + setup + subtotal + tidy, 0), + "missing": missing, + } + + +def _month_days(year: int, month: int) -> int: + nxt = datetime.date(year + month // 12, month % 12 + 1, 1) + return (nxt - datetime.date(year, month, 1)).days + + +def _holidays(src: dict[str, Any], year: int, month: int) -> tuple[int, list[str]]: + """그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" + head = f"{year:04d}-{month:02d}-" + named = { + d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head) + } + count = 0 + for day in range(1, _month_days(year, month) + 1): + iso = f"{head}{day:02d}" + if datetime.date(year, month, day).weekday() == 6 or iso in named: + count += 1 + return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())] + + +def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float: + mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1] + if num(mine) is not None: + return num(mine) + return num(((src.get("climate") or {}).get(kind) or [0] * 12)[month - 1]) or 0.0 + + +def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]: + """공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움.""" + start = {**(src.get("start") or {}), **(user.get("start") or {})} + year, month = int(start.get("year") or 2000), int(start.get("month") or 1) + left = [max(p, 0) for p in parts] + months: list[dict[str, Any]] = [] + total = 0 + while sum(left) > 0 or not months: + if len(months) >= MAX_MONTHS: + break + days = _month_days(year, month) + rain = _climate(src, user, "rain", month) + heat = _climate(src, user, "heat", month) + a = roundup(rain + heat, 1) + auto, names = _holidays(src, year, month) + mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}")) + b = mine if mine is not None else auto + c = a * b / days + off = rounddown(a + b - c, 0) + avail = days - off + cap = max(avail, 0) + use = [0.0, 0.0, 0.0] + room = cap + for i in range(3): + take = min(room, left[i]) + use[i] = take + left[i] -= take + room -= take + used = sum(use) + if cap <= 0: + off_used = days + elif used >= cap: + off_used = off + else: + off_used = roundup(off * used / cap, 0) + span = used + off_used + total += span + months.append( + { + "year": year, + "month": month, + "days": days, + "rain": rain, + "heat": heat, + "a": a, + "b": b, + "b_auto": auto, + "names": names, + "c": c, + "off": off, + "avail": avail, + "prep": use[0], + "work": use[1], + "tidy": use[2], + "used": used, + "off_used": off_used, + "span": span, + } # fmt: skip + ) + year, month = (year + 1, 1) if month == 12 else (year, month + 1) + first = months[0] + begin = datetime.date(first["year"], first["month"], 1) + end = begin + datetime.timedelta(days=max(total, 1) - 1) + return { + "start": {"year": first["year"], "month": first["month"]}, + "months": months, + "days": total, + "months_n": round_half(total / 30, 1), + "end": end.isoformat(), + } + + +def compute_schedule(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict: + """공기 한 벌 — {work: 공기산출, calendar: 공기집계}.""" + user = user or {} + work = work_table(src or {}, bill_rows or [], user) + parts = [work["prep"], work["subtotal"], work["tidy"]] + return {"work": work, "calendar": month_table(src or {}, user, parts)} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts new file mode 100644 index 000000000..0ae43dce9 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts @@ -0,0 +1,232 @@ +/* ============================================================================= + * B09_Draft_Schedule_Calc.ts + * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). + * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리. + * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 + * 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * ========================================================================== */ + +import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; + +const HOURS = 8; +const CREW = 10; +const PREP_DOCS = 2; +const PREP_SETUP = 2; +const TIDY = 4; +const SKIP_TOP = ["4", "G"]; +const OVERLAP_TOP = ["3"]; +const MAX_MONTHS = 120; + +const top = (section: unknown): string => String(section ?? "").split(".")[0]; + +function cfg(user: Row, key: string, fallback: number): number { + const value = num(user[key]); + return value === null || value < 0 ? fallback : value; +} + +function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { + const qty = num(item.qty) ?? 0; + let rate = num(c.rate); + if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100; + const dailyIn = num(c.daily); + const machine = unit?.machine as Row | null | undefined; + const labor = num(unit?.labor); + const out: Row = { + kind: "item", + key: item.key, + name: item.name || "", + spec: item.spec || "", + unit: item.unit || "", + lv: item.level || 1, + qty, + rate, + basis: "", + who: "", + per: null, + put: null, + q: null, + daily: null, + daily_in: dailyIn, + days: null, + }; + let need: number; + if (dailyIn !== null && dailyIn > 0) { + Object.assign(out, { basis: "입력", daily: dailyIn }); + need = (qty * rate) / 100 / dailyIn; + } else if (machine && (num(machine.hr) ?? 0) > 0) { + const hr = num(machine.hr) as number; + const units = num(c.units) || 1; + Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units }); + Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr }); + need = (((qty * rate) / 100) * hr) / (hours * units); + } else if (labor !== null && labor > 0) { + const crew = num(c.crew) || CREW; + Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor }); + need = (((qty * rate) / 100) * labor) / crew; + } else { + out.basis = "연결 없음"; + out.days = rate === 0 ? 0 : null; // 다 겹침 = 일작업량 없어도 0 + return out; + } + out.days = roundup(need, 1); + return out; +} + +/** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */ +export function workTable(src: Row, billRows: Row[], user: Row): Row { + const hours = cfg(user, "hours", HOURS) || HOURS; + const units: Row = src.units || {}; + const cfgs: Row = user.rows || {}; + const rows: Row[] = []; + let pending: Array<[Row, boolean]> = []; + for (const row of billRows) { + const level = row.level || 1; + if (row.kind !== "item") { + pending = pending.filter(([p]) => (p.level || 1) < level); + pending.push([row, false]); + continue; + } + const key = String(row.key ?? ""); + const qty = num(row.qty); + if ( + SKIP_TOP.includes(top(row.section)) || + key.startsWith("mat:") || + key === "fee" || + qty === null || + qty <= 0 + ) + continue; + for (const p of pending) { + if (p[1]) continue; + p[1] = true; + rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 }); + } + rows.push(workRow(row, units[key], cfgs[key] || {}, hours)); + } + const docs = cfg(user, "prep_docs", PREP_DOCS); + const setup = cfg(user, "prep_setup", PREP_SETUP); + const etc = cfg(user, "etc", 0); + const tidy = cfg(user, "tidy", TIDY); + const items = rows.filter((r) => r.kind === "item"); + const days = items.reduce((s: number, r) => s + (r.days || 0), 0); + const subtotal = roundHalf(days + etc, 0); + return { + hours, + rows, + prep_docs: docs, + prep_setup: setup, + prep: docs + setup, + etc, + subtotal, + tidy, + total: roundHalf(docs + setup + subtotal + tidy, 0), + missing: items.filter((r) => r.days === null).length, + }; +} + +const monthDays = (year: number, month: number): number => + new Date(Date.UTC(year, month, 0)).getUTCDate(); + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ +function holidays(src: Row, year: number, month: number): [number, string[]] { + const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; + const named = new Map( + ((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)), + ); + let count = 0; + for (let day = 1; day <= monthDays(year, month); day += 1) { + const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0; + if (sunday || named.has(`${head}${pad(day)}`)) count += 1; + } + const names = [...named.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([d, n]) => `${Number(d.slice(-2))}일 ${n}`); + return [count, names]; +} + +function climate(src: Row, user: Row, kind: string, month: number): number { + const mine = num(((user.climate || {})[kind] || [])[month - 1]); + if (mine !== null) return mine; + return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0; +} + +/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */ +export function monthTable(src: Row, user: Row, parts: number[]): Row { + const start: Row = { ...(src.start || {}), ...(user.start || {}) }; + let year = Math.trunc(Number(start.year) || 2000); + let month = Math.trunc(Number(start.month) || 1); + const left = parts.map((p) => Math.max(p, 0)); + const months: Row[] = []; + let total = 0; + while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) { + if (months.length >= MAX_MONTHS) break; + const days = monthDays(year, month); + const rain = climate(src, user, "rain", month); + const heat = climate(src, user, "heat", month); + const a = roundup(rain + heat, 1); + const [auto, names] = holidays(src, year, month); + const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); + const b = mine !== null ? mine : auto; + const c = (a * b) / days; + const off = rounddown(a + b - c, 0); + const avail = days - off; + const cap = Math.max(avail, 0); + const use = [0, 0, 0]; + let room = cap; + for (let i = 0; i < 3; i += 1) { + const take = Math.min(room, left[i]); + use[i] = take; + left[i] -= take; + room -= take; + } + const used = use[0] + use[1] + use[2]; + const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0); + const span = used + offUsed; + total += span; + months.push({ + year, + month, + days, + rain, + heat, + a, + b, + b_auto: auto, + names, + c, + off, + avail, + prep: use[0], + work: use[1], + tidy: use[2], + used, + off_used: offUsed, + span, + }); + if (month === 12) { + year += 1; + month = 1; + } else month += 1; + } + const first = months[0]; + const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5); + return { + start: { year: first.year, month: first.month }, + months, + days: total, + months_n: roundHalf(total / 30, 1), + end: end.toISOString().slice(0, 10), + }; +} + +/** 공기 한 벌 — {work: 공기산출, calendar: 공기집계} */ +export function computeSchedule(src: Row, billRows: Row[], user: Row): Row { + const u = user || {}; + const work = workTable(src || {}, billRows || [], u); + return { + work, + calendar: monthTable(src || {}, u, [work.prep, work.subtotal, work.tidy]), + }; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json b/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json new file mode 100644 index 000000000..b349e0842 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json @@ -0,0 +1,19 @@ +{ + "2024": [["2024-01-01", "신정"], ["2024-02-09", "설날 전날"], ["2024-02-10", "설날"], ["2024-02-11", "설날 다음날"], ["2024-02-12", "설날 대체 휴일"], ["2024-03-01", "삼일절"], ["2024-04-10", "국회의원 선거일"], ["2024-05-05", "어린이날"], ["2024-05-06", "어린이날 대체 휴일"], ["2024-05-15", "부처님오신날"], ["2024-06-06", "현충일"], ["2024-08-15", "광복절"], ["2024-09-16", "추석 전날"], ["2024-09-17", "추석"], ["2024-09-18", "추석 다음날"], ["2024-10-01", "국군의 날"], ["2024-10-03", "개천절"], ["2024-10-09", "한글날"], ["2024-12-25", "기독탄신일"]], + "2025": [["2025-01-01", "신정"], ["2025-01-27", "임시공휴일"], ["2025-01-28", "설날 전날"], ["2025-01-29", "설날"], ["2025-01-30", "설날 다음날"], ["2025-03-01", "삼일절"], ["2025-03-03", "삼일절 대체 휴일"], ["2025-05-05", "부처님오신날; 어린이날"], ["2025-05-06", "부처님오신날 대체 휴일; 어린이날 대체 휴일"], ["2025-06-03", "대통령 선거일"], ["2025-06-06", "현충일"], ["2025-08-15", "광복절"], ["2025-10-03", "개천절"], ["2025-10-05", "추석 전날"], ["2025-10-06", "추석"], ["2025-10-07", "추석 다음날"], ["2025-10-08", "추석 대체 휴일"], ["2025-10-09", "한글날"], ["2025-12-25", "기독탄신일"]], + "2026": [["2026-01-01", "신정"], ["2026-02-16", "설날 전날"], ["2026-02-17", "설날"], ["2026-02-18", "설날 다음날"], ["2026-03-01", "삼일절"], ["2026-03-02", "삼일절 대체 휴일"], ["2026-05-01", "노동절"], ["2026-05-05", "어린이날"], ["2026-05-24", "부처님오신날"], ["2026-05-25", "부처님오신날 대체 휴일"], ["2026-06-03", "지방선거일"], ["2026-06-06", "현충일"], ["2026-07-17", "제헌절"], ["2026-08-15", "광복절"], ["2026-08-17", "광복절 대체 휴일"], ["2026-09-24", "추석 전날"], ["2026-09-25", "추석"], ["2026-09-26", "추석 다음날"], ["2026-10-03", "개천절"], ["2026-10-05", "개천절 대체 휴일"], ["2026-10-09", "한글날"], ["2026-12-25", "기독탄신일"]], + "2027": [["2027-01-01", "신정"], ["2027-02-06", "설날 전날"], ["2027-02-07", "설날"], ["2027-02-08", "설날 다음날"], ["2027-02-09", "설날 대체 휴일"], ["2027-03-01", "삼일절"], ["2027-05-01", "노동절"], ["2027-05-03", "노동절 대체 휴일"], ["2027-05-05", "어린이날"], ["2027-05-13", "부처님오신날"], ["2027-06-06", "현충일"], ["2027-07-17", "제헌절"], ["2027-07-19", "제헌절 대체 휴일"], ["2027-08-15", "광복절"], ["2027-08-16", "광복절 대체 휴일"], ["2027-09-14", "추석 전날"], ["2027-09-15", "추석"], ["2027-09-16", "추석 다음날"], ["2027-10-03", "개천절"], ["2027-10-04", "개천절 대체 휴일"], ["2027-10-09", "한글날"], ["2027-10-11", "한글날 대체 휴일"], ["2027-12-25", "기독탄신일"], ["2027-12-27", "기독탄신일 대체 휴일"]], + "2028": [["2028-01-01", "신정"], ["2028-01-26", "설날 전날"], ["2028-01-27", "설날"], ["2028-01-28", "설날 다음날"], ["2028-03-01", "삼일절"], ["2028-05-01", "노동절"], ["2028-05-02", "부처님오신날"], ["2028-05-05", "어린이날"], ["2028-06-06", "현충일"], ["2028-07-17", "제헌절"], ["2028-08-15", "광복절"], ["2028-10-02", "추석 전날"], ["2028-10-03", "개천절; 추석"], ["2028-10-04", "추석 다음날"], ["2028-10-05", "추석 대체 휴일"], ["2028-10-09", "한글날"], ["2028-12-25", "기독탄신일"]], + "2029": [["2029-01-01", "신정"], ["2029-02-12", "설날 전날"], ["2029-02-13", "설날"], ["2029-02-14", "설날 다음날"], ["2029-03-01", "삼일절"], ["2029-05-01", "노동절"], ["2029-05-05", "어린이날"], ["2029-05-07", "어린이날 대체 휴일"], ["2029-05-20", "부처님오신날"], ["2029-05-21", "부처님오신날 대체 휴일"], ["2029-06-06", "현충일"], ["2029-07-17", "제헌절"], ["2029-08-15", "광복절"], ["2029-09-21", "추석 전날"], ["2029-09-22", "추석"], ["2029-09-23", "추석 다음날"], ["2029-09-24", "추석 대체 휴일"], ["2029-10-03", "개천절"], ["2029-10-09", "한글날"], ["2029-12-25", "기독탄신일"]], + "2030": [["2030-01-01", "신정"], ["2030-02-02", "설날 전날"], ["2030-02-03", "설날"], ["2030-02-04", "설날 다음날"], ["2030-02-05", "설날 대체 휴일"], ["2030-03-01", "삼일절"], ["2030-05-01", "노동절"], ["2030-05-05", "어린이날"], ["2030-05-06", "어린이날 대체 휴일"], ["2030-05-09", "부처님오신날"], ["2030-06-06", "현충일"], ["2030-07-17", "제헌절"], ["2030-08-15", "광복절"], ["2030-09-11", "추석 전날"], ["2030-09-12", "추석"], ["2030-09-13", "추석 다음날"], ["2030-10-03", "개천절"], ["2030-10-09", "한글날"], ["2030-12-25", "기독탄신일"]], + "2031": [["2031-01-01", "신정"], ["2031-01-22", "설날 전날"], ["2031-01-23", "설날"], ["2031-01-24", "설날 다음날"], ["2031-03-01", "삼일절"], ["2031-03-03", "삼일절 대체 휴일"], ["2031-05-01", "노동절"], ["2031-05-05", "어린이날"], ["2031-05-28", "부처님오신날"], ["2031-06-06", "현충일"], ["2031-07-17", "제헌절"], ["2031-08-15", "광복절"], ["2031-09-30", "추석 전날"], ["2031-10-01", "추석"], ["2031-10-02", "추석 다음날"], ["2031-10-03", "개천절"], ["2031-10-09", "한글날"], ["2031-12-25", "기독탄신일"]], + "2032": [["2032-01-01", "신정"], ["2032-02-10", "설날 전날"], ["2032-02-11", "설날"], ["2032-02-12", "설날 다음날"], ["2032-03-01", "삼일절"], ["2032-05-01", "노동절"], ["2032-05-03", "노동절 대체 휴일"], ["2032-05-05", "어린이날"], ["2032-05-16", "부처님오신날"], ["2032-05-17", "부처님오신날 대체 휴일"], ["2032-06-06", "현충일"], ["2032-07-17", "제헌절"], ["2032-07-19", "제헌절 대체 휴일"], ["2032-08-15", "광복절"], ["2032-08-16", "광복절 대체 휴일"], ["2032-09-18", "추석 전날"], ["2032-09-19", "추석"], ["2032-09-20", "추석 다음날"], ["2032-09-21", "추석 대체 휴일"], ["2032-10-03", "개천절"], ["2032-10-04", "개천절 대체 휴일"], ["2032-10-09", "한글날"], ["2032-10-11", "한글날 대체 휴일"], ["2032-12-25", "기독탄신일"], ["2032-12-27", "기독탄신일 대체 휴일"]], + "2033": [["2033-01-01", "신정"], ["2033-01-30", "설날 전날"], ["2033-01-31", "설날"], ["2033-02-01", "설날 다음날"], ["2033-02-02", "설날 대체 휴일"], ["2033-03-01", "삼일절"], ["2033-05-01", "노동절"], ["2033-05-02", "노동절 대체 휴일"], ["2033-05-05", "어린이날"], ["2033-05-06", "부처님오신날"], ["2033-06-06", "현충일"], ["2033-07-17", "제헌절"], ["2033-07-18", "제헌절 대체 휴일"], ["2033-08-15", "광복절"], ["2033-09-07", "추석 전날"], ["2033-09-08", "추석"], ["2033-09-09", "추석 다음날"], ["2033-10-03", "개천절"], ["2033-10-09", "한글날"], ["2033-10-10", "한글날 대체 휴일"], ["2033-12-25", "기독탄신일"], ["2033-12-26", "기독탄신일 대체 휴일"]], + "2034": [["2034-01-01", "신정"], ["2034-02-18", "설날 전날"], ["2034-02-19", "설날"], ["2034-02-20", "설날 다음날"], ["2034-02-21", "설날 대체 휴일"], ["2034-03-01", "삼일절"], ["2034-05-01", "노동절"], ["2034-05-05", "어린이날"], ["2034-05-25", "부처님오신날"], ["2034-06-06", "현충일"], ["2034-07-17", "제헌절"], ["2034-08-15", "광복절"], ["2034-09-26", "추석 전날"], ["2034-09-27", "추석"], ["2034-09-28", "추석 다음날"], ["2034-10-03", "개천절"], ["2034-10-09", "한글날"], ["2034-12-25", "기독탄신일"]], + "2035": [["2035-01-01", "신정"], ["2035-02-07", "설날 전날"], ["2035-02-08", "설날"], ["2035-02-09", "설날 다음날"], ["2035-03-01", "삼일절"], ["2035-05-01", "노동절"], ["2035-05-05", "어린이날"], ["2035-05-07", "어린이날 대체 휴일"], ["2035-05-15", "부처님오신날"], ["2035-06-06", "현충일"], ["2035-07-17", "제헌절"], ["2035-08-15", "광복절"], ["2035-09-15", "추석 전날"], ["2035-09-16", "추석"], ["2035-09-17", "추석 다음날"], ["2035-09-18", "추석 대체 휴일"], ["2035-10-03", "개천절"], ["2035-10-09", "한글날"], ["2035-12-25", "기독탄신일"]], + "2036": [["2036-01-01", "신정"], ["2036-01-27", "설날 전날"], ["2036-01-28", "설날"], ["2036-01-29", "설날 다음날"], ["2036-01-30", "설날 대체 휴일"], ["2036-03-01", "삼일절"], ["2036-03-03", "삼일절 대체 휴일"], ["2036-05-01", "노동절"], ["2036-05-03", "부처님오신날"], ["2036-05-05", "어린이날"], ["2036-05-06", "부처님오신날 대체 휴일"], ["2036-06-06", "현충일"], ["2036-07-17", "제헌절"], ["2036-08-15", "광복절"], ["2036-10-03", "개천절; 추석 전날"], ["2036-10-04", "추석"], ["2036-10-05", "추석 다음날"], ["2036-10-06", "추석 대체 휴일"], ["2036-10-07", "추석 대체 휴일"], ["2036-10-09", "한글날"], ["2036-12-25", "기독탄신일"]], + "2037": [["2037-01-01", "신정"], ["2037-02-14", "설날 전날"], ["2037-02-15", "설날"], ["2037-02-16", "설날 다음날"], ["2037-02-17", "설날 대체 휴일"], ["2037-03-01", "삼일절"], ["2037-03-02", "삼일절 대체 휴일"], ["2037-05-01", "노동절"], ["2037-05-05", "어린이날"], ["2037-05-22", "부처님오신날"], ["2037-06-06", "현충일"], ["2037-07-17", "제헌절"], ["2037-08-15", "광복절"], ["2037-08-17", "광복절 대체 휴일"], ["2037-09-23", "추석 전날"], ["2037-09-24", "추석"], ["2037-09-25", "추석 다음날"], ["2037-10-03", "개천절"], ["2037-10-05", "개천절 대체 휴일"], ["2037-10-09", "한글날"], ["2037-12-25", "기독탄신일"]], + "2038": [["2038-01-01", "신정"], ["2038-02-03", "설날 전날"], ["2038-02-04", "설날"], ["2038-02-05", "설날 다음날"], ["2038-03-01", "삼일절"], ["2038-05-01", "노동절"], ["2038-05-03", "노동절 대체 휴일"], ["2038-05-05", "어린이날"], ["2038-05-11", "부처님오신날"], ["2038-06-06", "현충일"], ["2038-07-17", "제헌절"], ["2038-07-19", "제헌절 대체 휴일"], ["2038-08-15", "광복절"], ["2038-08-16", "광복절 대체 휴일"], ["2038-09-12", "추석 전날"], ["2038-09-13", "추석"], ["2038-09-14", "추석 다음날"], ["2038-09-15", "추석 대체 휴일"], ["2038-10-03", "개천절"], ["2038-10-04", "개천절 대체 휴일"], ["2038-10-09", "한글날"], ["2038-10-11", "한글날 대체 휴일"], ["2038-12-25", "기독탄신일"], ["2038-12-27", "기독탄신일 대체 휴일"]], + "2039": [["2039-01-01", "신정"], ["2039-01-23", "설날 전날"], ["2039-01-24", "설날"], ["2039-01-25", "설날 다음날"], ["2039-01-26", "설날 대체 휴일"], ["2039-03-01", "삼일절"], ["2039-04-30", "부처님오신날"], ["2039-05-01", "노동절"], ["2039-05-02", "부처님오신날 대체 휴일"], ["2039-05-03", "노동절 대체 휴일"], ["2039-05-05", "어린이날"], ["2039-06-06", "현충일"], ["2039-07-17", "제헌절"], ["2039-07-18", "제헌절 대체 휴일"], ["2039-08-15", "광복절"], ["2039-10-01", "추석 전날"], ["2039-10-02", "추석"], ["2039-10-03", "개천절; 추석 다음날"], ["2039-10-04", "추석 대체 휴일"], ["2039-10-05", "추석 대체 휴일"], ["2039-10-09", "한글날"], ["2039-10-10", "한글날 대체 휴일"], ["2039-12-25", "기독탄신일"], ["2039-12-26", "기독탄신일 대체 휴일"]], + "2040": [["2040-01-01", "신정"], ["2040-02-11", "설날 전날"], ["2040-02-12", "설날"], ["2040-02-13", "설날 다음날"], ["2040-02-14", "설날 대체 휴일"], ["2040-03-01", "삼일절"], ["2040-05-01", "노동절"], ["2040-05-05", "어린이날"], ["2040-05-07", "어린이날 대체 휴일"], ["2040-05-18", "부처님오신날"], ["2040-06-06", "현충일"], ["2040-07-17", "제헌절"], ["2040-08-15", "광복절"], ["2040-09-20", "추석 전날"], ["2040-09-21", "추석"], ["2040-09-22", "추석 다음날"], ["2040-10-03", "개천절"], ["2040-10-09", "한글날"], ["2040-12-25", "기독탄신일"]] +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Source.py b/B09_Estimation/draft/B09_Draft_Schedule_Source.py new file mode 100644 index 000000000..3f9e7972c --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Source.py @@ -0,0 +1,136 @@ +"""B09 공기 초안 서버 원천(PLAN 252-2 · 사용자 161번 — draft 폴더째 지우면 사라짐). + +본 코드 훅 = `B09_Estimation_Router.payload` · `B09_Estimation_Store.clean_user` 가 이 모듈을 + 가져오지 못하면 건너뜀(공기 값 · 탭 없이 그대로). + inputs["schedule"] = 원천(줄마다 단위당 자원 · 공휴일 표 · 기후 기본값 · 착공 기본값) + sheets["schedule"] = 서버 정본 셈(`…_Calc.compute_schedule` · 화면은 TS 짝으로 즉시) +단위당 자원 = 설계내역서 항목 줄의 호표 1 단위 역전개(`B09_Estimation_Summary.expand`) — + 주 장비 = 단위당 시간이 가장 긴 장비 · 인력 = 단위당 인 합(직종 무관) +공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays` + 목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개) +기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) — + 지역마다 사용자 고침 +사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수) +""" + +from __future__ import annotations + +import datetime +import json +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num +from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule + +HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json") +CLIMATE = { + "label": "경남 거창(참고 설계서 · 강수 = 일강수량 5mm 이상 · 혹서 = 33℃ 이상)", + "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], + "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], +} +START_MONTH = 3 +NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy") + + +def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: + """설계내역서 항목 줄 → {줄 키: {machine: {key, name, hr} | None, labor: 인 | None}}(1 단위).""" + lines_of = hopyo_lines(sheets) + machines = {row["key"]: row for row in sheets.get("machine") or []} + out: dict[str, dict[str, Any]] = {} + for row in (sheets.get("bill") or {}).get("rows") or []: + key = str((row.get("ref") or {}).get("key") or "") + if row.get("kind") != "item" or key not in lines_of: + continue + used: dict[str, dict[str, float]] = {} + expand(lines_of, key, 1.0, used) + machine = None + hours = used.get("machine") or {} + if hours: + mkey, hr = max(hours.items(), key=lambda kv: kv[1]) + if hr > 0: + src = machines.get(mkey) or {} + name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey + machine = {"key": mkey, "name": name, "hr": hr} + labor = sum((used.get("labor") or {}).values()) + out[row["key"]] = {"machine": machine, "labor": labor if labor > 0 else None} + return out + + +def holiday_table() -> dict[str, list[list[str]]]: + try: + return json.loads(HOLIDAYS.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None) -> dict[str, Any]: + today = today or datetime.date.today() + return { + "units": unit_resources(sheets), + "holidays": holiday_table(), + "climate": CLIMATE, + "start": {"year": today.year + 1, "month": START_MONTH}, + } + + +def attach(inputs: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) -> None: + """payload 훅 — 원천 · 서버 정본 셈을 끼움.""" + inputs["schedule"] = schedule_inputs(sheets) + bill = (sheets.get("bill") or {}).get("rows") or [] + sheets["schedule"] = compute_schedule(inputs["schedule"], bill, user.get("schedule") or {}) + + +def _positive(value: Any) -> float | None: + v = num(value) + return v if v is not None and v > 0 else None + + +def _twelve(values: Any) -> list[float | None] | None: + if not isinstance(values, list) or len(values) != 12: + return None + return [v if (v := num(x)) is not None and v >= 0 else None for x in values] + + +def clean_schedule(raw: Any) -> dict[str, Any]: + """user["schedule"] 다듬기 — 모르는 칸 버림 · 수 칸은 0 이상 수 · 빈 것 = 칸 없음.""" + if not isinstance(raw, dict): + return {} + out: dict[str, Any] = {} + for field in NUMBERS: + v = num(raw.get(field)) + if v is not None and 0 <= v <= 1000: + out[field] = v + start = raw.get("start") if isinstance(raw.get("start"), dict) else {} + year, month = num(start.get("year")), num(start.get("month")) + if year and month and 2000 <= year <= 2100 and 1 <= month <= 12: + out["start"] = {"year": int(year), "month": int(month)} + climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {} + kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))} + if kept: + out["climate"] = kept + holiday = raw.get("holiday") if isinstance(raw.get("holiday"), dict) else {} + marks = { + str(k): v + for k, x in holiday.items() + if len(str(k)) == 7 and (v := num(x)) is not None and 0 <= v <= 31 + } + if marks: + out["holiday"] = marks + rows = {} + for key, cell in (raw.get("rows") or {}).items() if isinstance(raw.get("rows"), dict) else []: + if not isinstance(cell, dict) or not str(key) or len(str(key)) > 200: + continue + one = {} + rate = num(cell.get("rate")) + if rate is not None and 0 <= rate <= 100: + one["rate"] = rate + for field in ("units", "crew", "daily"): + if (v := _positive(cell.get(field))) is not None: + one[field] = v + if one: + rows[str(key)] = one + if rows: + out["rows"] = rows + return out diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts new file mode 100644 index 000000000..ad39247d6 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -0,0 +1,306 @@ +/* ============================================================================= + * B09_Draft_Schedule_UI.ts + * B09 위 탭 「공기」(PLAN 252-3 · 사용자 161번 · 초안) — 아래 탭 공기집계 · 공기산출. + * 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS · + * DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐 + * 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule` + * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / + * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) + * ========================================================================== */ + +import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import { num, type Row } from "../B09_Estimation_Calc_Base"; +import { + DRAFT, + parseNumber, + qty, + TAB_GROUPS, + TABS, + text, + type TabContext, + type TabId, + type TabTable, +} from "../B09_Estimation_UI_Tabs"; +import { computeSchedule } from "./B09_Draft_Schedule_Calc"; + +const SUM = "schedule_sum" as TabId; +const CALC = "schedule_calc" as TabId; + +/** 사용자 공기 값 고침 → 캐시 · 바로 다시 셈 */ +function change(ctx: TabContext, fn: (s: Row) => void): void { + const next: Row = structuredClone(ctx.user.schedule || {}); + fn(next); + ctx.setUser({ ...ctx.user, schedule: next }); +} + +/** 칸 값 — 빈칸 = 지움(기본값) · 수 아님 = 무시 */ +function setNum(box: Row, key: string, value: string, min = 0, max = 1000): void { + const v = parseNumber(value); + if (v === undefined) return; + if (v === null || v < min || v > max) delete box[key]; + else box[key] = v; +} + +const pct = (n: number): string => String(Math.round(n * 10) / 10); + +/* ── 공기산출 ─────────────────────────────────────────────────────────── */ + +const CALC_COLUMNS: DataColumn[] = [ + text("name", "구분 · 명칭", 220), + text("spec", "규격", 120), + qty("qty", "시공량", 2), + text("unit", "단위", 56), + text("basis", "기준", 64), + text("who", "주 자원", 150), + qty("per", "단위당 시간 · 인", 4), + qty("put", "투입 대 · 인", 1), + qty("q", "시간당 작업량 Q", 2), + qty("daily", "일작업량", 2), + qty("rate", "반영률 %", 1), + qty("days", "작업일수", 1), + text("note", "비고", 220), +]; +const FIXED: Record = { + prep_docs: "days", + prep_setup: "days", + hours: "put", + etc: "days", + tidy: "days", +}; + +function itemNote(r: Row): string { + if (r.rate === 0) return "다른 공종과 겹침(반영 0 %)"; + if (r.basis === "연결 없음") return "설계내역서 [단가연결] 하면 자동 · 또는 일작업량 입력"; + if (r.basis === "장비") return `Q = 1 ÷ ${r.per} 시간`; + if (r.basis === "인력") return `${r.put}인 ÷ 단위당 ${r.per}인`; + return "일작업량 = 입력"; +} + +export function buildScheduleCalc(ctx: TabContext): TabTable | string { + const sch: Row | undefined = ctx.sheets.schedule; + if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; + const w: Row = sch.work; + const rows: DataRow[] = [ + { key: "h:prep", name: "1. 준비기간", lv: 1, head: true }, + { key: "prep_docs", name: "각종 서류 및 착수 준비", lv: 2, days: w.prep_docs }, + { key: "prep_setup", name: "공종별 준비기간", lv: 2, days: w.prep_setup, note: "자재운반 등" }, + { key: "h:work", name: "2. 공종별 작업일수", lv: 1, head: true }, + { + key: "hours", + name: "일 작업시간", + lv: 2, + put: w.hours, + unit: "시간", + note: "장비 일작업량 = 이 시간 × Q × 대수", + }, + ...(w.rows as Row[]).map((r) => + r.kind === "head" + ? { key: r.key, name: r.name, lv: r.lv + 1, head: true } + : { + ...r, + lv: r.lv + 1, + qty: r.qty, + per: r.per ?? "", + put: r.put ?? "", + q: r.q ?? "", + daily: r.daily ?? "", + days: r.days ?? "", + note: itemNote(r), + item: true, + }, + ), + { key: "etc", name: "기타(현장여건 감안)", lv: 2, days: w.etc }, + { + key: "subtotal", + name: "작업일수 소계", + lv: 2, + days: w.subtotal, + note: "ROUND(Σ 작업일수 + 기타, 0)", + head: true, + }, + { key: "tidy", name: "3. 정리기간", lv: 1, days: w.tidy, note: "1개월 범위 안", head: true }, + ]; + const missing = w.missing ? ` · 연결 없음 ${w.missing}줄` : ""; + return { + columns: CALC_COLUMNS, + rows, + total: { name: "총작업일수", days: w.total, note: "준비 + 소계 + 정리 → 공기집계로" }, + frozen: 1, + level: { key: "name", of: (i) => Number(rows[i].lv) || 1 }, + rowClass: (i) => (rows[i].head ? "is-title" : null), + // 사용자 덮개 칸(기본값과 다름) = 설계내역서 고친 칸 꼴 + cellClass: (i, key) => { + const k = String(rows[i].key); + const mine: Row = ctx.user.schedule || {}; + if (FIXED[k]) return FIXED[k] === key && num(mine[k]) !== null ? "is-edited" : null; + const own: Row = mine.rows?.[k] || {}; + const field = key === "put" ? (rows[i].basis === "장비" ? "units" : "crew") : key; + return rows[i].item && ["rate", "daily", "put"].includes(key) && num(own[field]) !== null + ? "is-edited" + : null; + }, + hint: `작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비(일 작업시간 × Q × 대수) · 인력(인원 ÷ 단위당 인) · 입력${missing}`, + editable: (i, key) => { + const r = rows[i]; + if (FIXED[String(r.key)]) return FIXED[String(r.key)] === key; + if (!r.item) return false; + if (key === "put") return r.basis === "장비" || r.basis === "인력"; + return key === "daily" || key === "rate"; + }, + onEdit: (i, key, value) => { + const r = rows[i]; + const k = String(r.key); + change(ctx, (s) => { + if (FIXED[k]) + return setNum(s, k, value, k === "hours" ? 0.5 : 0, k === "hours" ? 24 : 1000); + s.rows ??= {}; + const box: Row = (s.rows[k] ??= {}); + if (key === "rate") setNum(box, "rate", value, 0, 100); + else if (key === "daily") setNum(box, "daily", value, 1e-9, 1e9); + else if (key === "put") + setNum(box, r.basis === "장비" ? "units" : "crew", value, 1e-9, 1e4); + if (!Object.keys(box).length) delete s.rows[k]; + }); + }, + }; +} + +/* ── 공기집계 ─────────────────────────────────────────────────────────── */ + +const LINES: Array<[string, string, string, string]> = [ + // [키(달 값 칸), 구분, 세부, 비고] + ["days", "달력일수", "", ""], + ["rain", "기후여건 A", "강수일", "일강수량 5mm 이상(달마다 · 해 무관)"], + ["heat", "기후여건 A", "혹서일", "33℃ 이상"], + ["a", "기후여건 A", "소계", "ROUNDUP(강수 + 혹서, 1)"], + ["b", "법정공휴일 B", "", "일요일 + 공휴일 · 대체 · 선거일(풍선 = 이름 · 고치면 그 달 덮개)"], + ["c", "중복일수 C", "", "A × B ÷ 달력일수"], + ["off", "비작업일수", "", "ROUNDDOWN(A + B − C, 0)"], + ["avail", "작업가능일수", "", "달력일수 − 비작업일수"], + ["prep", "공사기간", "준비기간", "작업가능일을 준비 → 작업 → 정리 차례로 채움"], + ["work", "공사기간", "작업일수", ""], + ["tidy", "공사기간", "정리기간", ""], + ["used", "공사기간", "작업일수 계", ""], + ["off_used", "공사기간", "비작업일수", "다 쓴 달 = 전부 · 마지막 달 = 쓴 비율 올림"], + ["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"], +]; + +export function buildScheduleSum(ctx: TabContext): TabTable | string { + const sch: Row | undefined = ctx.sheets.schedule; + if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; + const cal: Row = sch.calendar; + const months: Row[] = cal.months; + const src: Row = ctx.payload.inputs?.schedule || {}; + const mine: Row = ctx.user.schedule || {}; + const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`; + const columns: DataColumn[] = [ + text("label", "구분", 96), + text("sub", "세부", 96), + ...months.map((m, i) => ({ + ...qty(`m${i}`, `${m.month}월`, 2), + group: [`${m.year}년`], + width: 58, + })), + qty("sum", "계", 2), + text("note", "비고", 340), + ]; + const rows: DataRow[] = [ + { + key: "start", + label: "착공", + sub: start, + note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, + }, + ...LINES.map(([key, label, sub, note]) => { + const row: DataRow = { key, label, sub, note }; + let sum = 0; + months.forEach((m, i) => { + row[`m${i}`] = m[key]; + sum += Number(m[key]) || 0; + }); + row.sum = sum; + if (key === "rain" && src.climate?.label) + row.note = `${note} · 기본값 = ${src.climate.label}`; + return row; + }), + ]; + const monthOf = (key: string): number => (key.startsWith("m") ? Number(key.slice(1)) : -1); + const ym = (m: Row): string => `${m.year}-${String(m.month).padStart(2, "0")}`; + return { + columns, + rows, + frozen: 2, + mergeSame: ["label"], + rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + tip: (i, key) => { + const m = months[monthOf(key)]; + if (!m || rows[i].key !== "b") return null; + const names = (m.names as string[]).join(" · "); + const own = num(mine.holiday?.[ym(m)]); + return [ + names || "공휴일 없음(일요일만)", + own !== null ? `자동 ${m.b_auto} → 덮개 ${own}` : "", + ] + .filter(Boolean) + .join(" · "); + }, + cellClass: (i, key) => { + const m = months[monthOf(key)]; + if (!m) return null; + const k = String(rows[i].key); + const own = + k === "b" + ? num(mine.holiday?.[ym(m)]) + : k === "rain" || k === "heat" + ? num(mine.climate?.[k]?.[m.month - 1]) + : null; + return own !== null ? "is-edited" : null; // 사용자 덮개(기본값과 다름) + }, + editable: (i, key) => { + const k = String(rows[i].key); + if (k === "start") return key === "sub"; + return (k === "rain" || k === "heat" || k === "b") && monthOf(key) >= 0; + }, + onEdit: (i, key, value) => { + const k = String(rows[i].key); + change(ctx, (s) => { + if (k === "start") { + const hit = /(\d{4})\D+(\d{1,2})/.exec(value); + if (!value.trim()) delete s.start; + else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) + s.start = { year: Number(hit[1]), month: Number(hit[2]) }; + return; + } + const m = months[monthOf(key)]; + if (k === "b") { + s.holiday ??= {}; + setNum(s.holiday, ym(m), value, 0, 31); + if (!Object.keys(s.holiday).length) delete s.holiday; + return; + } + s.climate ??= {}; + const list: Array = s.climate[k] ?? Array(12).fill(null); + const v = parseNumber(value); + if (v === undefined) return; + list[m.month - 1] = v === null || v < 0 || v > 31 ? null : v; + if (list.some((x) => x !== null)) s.climate[k] = list; + else delete s.climate[k]; + if (!Object.keys(s.climate).length) delete s.climate; + }); + }, + }; +} + +/* ── 등록 ─────────────────────────────────────────────────────────────── */ + +if (!TABS.some(([id]) => id === SUM)) { + TABS.push([SUM, "공기집계"], [CALC, "공기산출"]); + TAB_GROUPS.push(["공기", [SUM, CALC]]); + DRAFT.builders[SUM] = buildScheduleSum; + DRAFT.builders[CALC] = buildScheduleCalc; + DRAFT.extend.push((payload, user, sheets) => { + const src = payload.inputs?.schedule; + if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {}); + }); +} diff --git a/resources/tester/test_252_1_b09_schedule.py b/resources/tester/test_252_1_b09_schedule.py new file mode 100644 index 000000000..84b8b3f69 --- /dev/null +++ b/resources/tester/test_252_1_b09_schedule.py @@ -0,0 +1,212 @@ +"""PLAN 252 — B09 공기 초안(공기산출 · 공기집계 셈 짝 · 서버 원천 · 사용자 값 다듬기).""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402 +from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402 + clean_schedule, + holiday_table, + schedule_inputs, + unit_resources, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +#: 참고 설계서(25 거창) 공기산출 — [이름, 시공량, 일작업량(= 8 × Q)] · 기타공 2일 +GEOCHANG = [ + ("토사절취", 7641, 1206.4), ("암석절취", 2669, 160), ("암석절취", 297, 48), + ("측구터파기", 110, 362.24), ("측구터파기", 103, 20), ("지장목제거", 10782, 4519.76), + ("초류종자파종", 2718.4, 2400), ("전석메쌓기", 540, 74.4), ("유입구골막이", 4, 4), + ("유출구물받이", 16, 6), ("터파기", 757, 838.24), ("터파기", 57, 80), ("터파기", 57, 48), + ("되메우기", 523, 904.16), ("관구체다짐", 324, 180), ("파형강관매설", 185, 160), + ("배수관날개벽", 16, 4), ("BOX암거", 5, 0.8), ("BOX암거 난간", 13, 6.4), + ("콘크리트포장", 2170, 200), ("혼합석부설", 2870, 1108), ("노견줄떼", 820, 255.2), +] # fmt: skip +#: 같은 설계서 공기집계 — 기후(강수 + 혹서) · 법정공휴일(2026 기준 · 그 설계서 값) +B_REF = [5, 7, 6, 4, 7, 5, 4, 7, 7, 7, 5, 5] + + +def _bill(rows): + out = [{"kind": "title", "key": "sec:1", "name": "토사", "level": 1, "section": "1"}] + for i, (name, qty, _d) in enumerate(rows): + out.append({"kind": "item", "key": f"r{i}", "name": name, "qty": qty, "level": 2, + "section": "1.2", "unit": "㎥"}) # fmt: skip + return out + + +def _ref_user(): + return { + "rows": {f"r{i}": {"daily": d} for i, (_n, _q, d) in enumerate(GEOCHANG)}, + "etc": 2, + "start": {"year": 2026, "month": 3}, + "holiday": {f"2026-{m:02d}": b for m, b in enumerate(B_REF, 1)}, + } + + +SRC = { + "units": {}, + "holidays": holiday_table(), + "climate": { + "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], + "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], + }, + "start": {"year": 2027, "month": 3}, +} + + +def test_참고_설계서_거창_공기산출_재현(): + work = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["work"] + days = [r["days"] for r in work["rows"] if r["kind"] == "item"] + assert days[:6] == [6.4, 16.7, 6.2, 0.4, 5.2, 2.4] # 설계서 F7 · F8 · F9 · F10 · F11 · F14 + assert days[-3:] == [10.9, 2.6, 3.3] # F37 · F38 · F39 + assert (work["prep"], work["subtotal"], work["tidy"], work["total"]) == (4, 87, 4, 95) + assert [r["kind"] for r in work["rows"][:2]] == ["head", "item"] # 묶음 줄 먼저 + + +def test_참고_설계서_거창_작업가능일_채움(): + cal = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["calendar"] + months = cal["months"] + # 설계서 3 ~ 7월 작업가능일수(달력 − ROUNDDOWN(A + B − A×B÷달력)) + assert [m["avail"] for m in months] == [23, 23, 21, 21, 16] + assert [(m["prep"], m["work"], m["tidy"]) for m in months] == [ + (4, 19, 0), (0, 23, 0), (0, 21, 0), (0, 21, 0), (0, 3, 4), + ] # fmt: skip + # 다 쓴 달 = 비작업 전부 · 마지막 달 = ROUNDUP(15 × 7 ÷ 16) = 7 + assert [m["off_used"] for m in months] == [8, 7, 10, 9, 7] + assert (cal["days"], cal["months_n"], cal["end"]) == (136, 4.5, "2026-07-14") + + +def test_공휴일_자동_2026(): + cal = compute_schedule(SRC, [], {"start": {"year": 2026, "month": 1}, "tidy": 400}) + auto = [m["b_auto"] for m in cal["calendar"]["months"][:12]] + # 설계서 값과 다른 달 = 5월 노동절 · 6월 지방선거 · 7월 제헌절(공휴일 법 바뀜 · 목록이 넣음) + assert auto == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5] + may = cal["calendar"]["months"][4]["names"] + assert "5일 어린이날" in may and "25일 부처님오신날 대체 휴일" in may + assert cal["calendar"]["months"][1]["days"] == 28 # 2026.2 = 28일(설계서는 29) + + +def test_장비_인력_입력_반영률(): + src = {**SRC, "units": { + "a": {"machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, "labor": 0.01}, + "b": {"machine": None, "labor": 1.5}, + "c": {"machine": None, "labor": None}, + }} # fmt: skip + bill = [ + {"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2}, + {"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2}, + {"kind": "item", "key": "c", "qty": 5, "section": "2.3", "level": 2}, + {"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2}, + {"kind": "item", "key": "mat:x", "qty": 9, "section": "4", "level": 2}, + {"kind": "item", "key": "z", "qty": 0, "section": "1.2", "level": 2}, + ] + rows = {r["key"]: r for r in compute_schedule(src, bill, {})["work"]["rows"]} + a, b, c, h = rows["a"], rows["b"], rows["c"], rows["h"] + # 장비 = 1000 × 0.0066 ÷ 8 = 0.825 → 0.9 · Q = 1 ÷ 0.0066 + assert (a["basis"], a["days"], round(a["q"], 2), a["put"]) == ("장비", 0.9, 151.52, 1) + assert (b["basis"], b["days"], b["put"]) == ("인력", 12, 10) # 80 × 1.5 ÷ 10 + assert (c["basis"], c["days"]) == ("연결 없음", None) + assert (h["rate"], h["days"]) == (0, 0) # 운반공 기본 0 = 단위당 자원 없어도 0일 + assert "mat:x" not in rows and "z" not in rows # 자재대 · 수량 0 = 줄 없음 + user = {"rows": {"a": {"units": 2, "rate": 50}, "b": {"crew": 5}, "c": {"daily": 2}}} + rows = {r["key"]: r for r in compute_schedule(src, bill, user)["work"]["rows"]} + assert rows["a"]["days"] == 0.3 # 1000 × 0.5 × 0.0066 ÷ 16 = 0.20625 → 0.3 + assert rows["b"]["days"] == 24 and (rows["c"]["basis"], rows["c"]["days"]) == ("입력", 2.5) + + +def _ts_schedule(src, bill, user): + base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) + calc = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_Calc.ts").read_text("utf-8") + js = ( + base + IMPORTS.sub("", calc) + + f"\nconsole.log(JSON.stringify(computeSchedule({json.dumps(src, ensure_ascii=False)}, " + f"{json.dumps(bill, ensure_ascii=False)}, {json.dumps(user, ensure_ascii=False)})));" + ) # fmt: skip + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_거울_TS_가_같은_값(): + src = {**SRC, "units": {"r0": {"machine": {"key": "M", "name": "굴", "hr": 0.0066}}, + "r1": {"labor": 1.5}}} # fmt: skip + cases = [ + (SRC, _bill(GEOCHANG), _ref_user()), + (src, _bill(GEOCHANG[:4]), {"rows": {"r0": {"rate": 50}}, + "start": {"year": 2027, "month": 11}, + "climate": {"rain": [9] * 12, "heat": [None] * 12}}), + (SRC, [], {}), + ] # fmt: skip + for src_, bill, user in cases: + py = json.loads(json.dumps(compute_schedule(src_, bill, user), ensure_ascii=False)) + assert _ts_schedule(src_, bill, user) == py + + +def test_사용자_값_다듬기(): + raw = { + "hours": 9, "tidy": -1, "etc": "x", "start": {"year": 2027, "month": 13}, + "climate": {"rain": [1] * 12, "heat": [1, 2]}, + "holiday": {"2027-05": 6, "bad": 3, "2027-06": 40}, + "rows": {"a": {"rate": 50, "units": 0, "crew": 5, "daily": None, "x": 1}, + "b": {"rate": 120}}, + "junk": 1, + } # fmt: skip + assert clean_schedule(raw) == { + "hours": 9, + "climate": {"rain": [1] * 12}, + "holiday": {"2027-05": 6}, + "rows": {"a": {"rate": 50, "crew": 5}}, + } + assert clean_schedule(None) == {} + from B09_Estimation.B09_Estimation_Store import clean_user + + assert clean_user({"schedule": {"tidy": 3}})["schedule"] == {"tidy": 3} + assert "schedule" not in clean_user({"schedule": {"junk": 1}}) + + +def test_서버_원천_단위당_자원(): + sheets = { + "bill": {"rows": [ + {"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}, + {"kind": "item", "key": "hand:x", "ref": {"tab": "dg", "key": "D1"}}, + {"kind": "item", "key": "earth:5", "ref": None}, + ]}, + "sg": [{"key": "S1", "lines": [ + {"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066}, + {"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002}, + {"kind": "labor", "ref": {"key": "L1"}, "qty": 0.01}, + ]}], + "daega_blocks": [{"key": "D1", "lines": [ + {"kind": "labor", "ref": {"key": "L1"}, "qty": 0.5}, + {"kind": "sangeun", "ref": {"key": "S1"}, "qty": 2}, + {"kind": "labor", "ref": {"key": "L2"}, "qty": 1.0}, + ]}], + "machine": [{"key": "M1", "name": "굴착기", "spec": "0.7㎥"}], + } # fmt: skip + units = unit_resources(sheets) + assert units["earth:4"]["machine"] == {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066} + assert units["earth:4"]["labor"] == 0.01 + # 겹 호표 = 끝까지(산근 2 단위 → 굴착기 0.0132 시간 · 인 0.5 + 0.02 + 1.0) + assert round(units["hand:x"]["machine"]["hr"], 6) == 0.0132 + assert round(units["hand:x"]["labor"], 6) == 1.52 + assert "earth:5" not in units + got = schedule_inputs(sheets, __import__("datetime").date(2026, 10, 11)) + assert got["start"] == {"year": 2027, "month": 3} and "2027" in got["holidays"] + + +def test_본_코드_훅_지워도_돌게(): + store = (ROOT / "B09_Estimation/B09_Estimation_Store.py").read_text("utf-8") + router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8") + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "except ImportError" in store and "except ImportError" in router + assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell From df2145e85317ee6390571930af0aec30d537f36c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:19:32 +0900 Subject: [PATCH 030/151] =?UTF-8?q?chore(plan):=2010-11=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20162(=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20B09=20=C2=B7=20B06=20=EC=83=81=EC=84=B8=20=C2=B7=20?= =?UTF-8?q?23=20=EB=AC=B6=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .../raw/요구_1010_2/그림_162-21_횡단배수_글.png | Bin 0 -> 46094 bytes docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 2 files changed, 1 insertion(+) create mode 100644 docs/raw/요구_1010_2/그림_162-21_횡단배수_글.png diff --git a/docs/raw/요구_1010_2/그림_162-21_횡단배수_글.png b/docs/raw/요구_1010_2/그림_162-21_횡단배수_글.png new file mode 100644 index 0000000000000000000000000000000000000000..cece5b8c1dd9c658342ba4b14d3cb3e4c3352cb0 GIT binary patch literal 46094 zcmeFZtLk*su3cvlq9`wgiAIEmgoK3o^^3SN64Hw+B&26%sL!9S zM9@9^iG=h9>8rSis!Ph=y{iq$oac5J*g{{ul4x1!w&KKmUK<50)>GGWZ-M``(^4h>|tN{PJ_lRRw&9>`ZO>=0IODb#7rb zlIjO_O&3s0$BEa|?lT3gtiG>a(7_KI5-mYaY2``|2WQV0;1%V9O0_KC=v45MIL#!? ztkz&?hJcbbrCBu?a(8l=Y0^uu8kczXDm01Wq+v|wOjJtAqQz4NmyM)?jZ20COrt@Y zuBx$|sbM&OeU_0goPqB1>@#X&bG6G(x=un8RZd#nz1QAWI)F|C9cN7~bV#l}OpllE zRw(r>@Z#`}oP6=|RNMBo3C|ao3j_keBKI@Zu2FJPz4df8od{Ezd0YCHIB{Q3P_v~$ zr7nu1;SVu+^5^^V^d$a6=NseHFQ#?s4@Vb#8rChB&D3*o(F!fct5NmVsYh2Ahs)ZH zH2;B|z9L}fdfb`clcj+1Qk2<%FNOc}U;)U@>EF&jdxz@q>=RN$)-IYGih^Wz z32Iju1YoMoV-oxYMymq0RHQ@7}f9kH)agd(EnqQOqBp{t7cXoizDwA8}0A zO_h)QU=}&B{w*Gk$g+X$k*B;S!QU(_g?Z1;K8&dgYIU_U{|!e3=U)$ZDwd{5O$8W`%8%#k+S9qs;5RE@VJRu5y`vy)8eoTuL|a%|Z8 zNWseS2O55T9~mC7EqdTK#tElYx4*_N_(}no_;b`rrRTQS+4>m|i=)4~e=p8%lY~dP zgrTvC^bNO;5f5d+(OyY+msk0s)jS!}Z0()IQHJ$ZSL^?uZvKI_=#eeXI{1tL$5 zzj-x!rd53VDBt^d+Y#RSQ=}8)pE2l9#$fo5VQ80fCNJ=DxLa9n*YD3tMu%vsiXx{S ztsCYGKF$_y_fX|q#`%HndYm;NDj|IJgA08p<~0cno;fm3nx%XJ?TQS}V@IS4q*o{5M_nrDkSyO2 zpq(M(#YyvRxpj}w>`3?NhG}2C{D*f{LzYd^Zj79dT?qK5&DCDWoQV_qVGo-#M~c|9 zzxxpW@u{SI`!Ix3h>!ECCT9rxCv-4e(rY*8@!W$|&!Pb1ONn~^iv7v>VBw>!zFnDC z>A~S5sQEO#9Pr4Xqlrrb`pyC0Iaqyu*u6DkD(<{BNq{APO)DiY2~MljcAhzjo3~Qd zmV>?EFZ*L@w3R*_DYzw{TXX1j=m+DTc-f|;9@e#Gr%8ezX83!jYHqJ4^NY=iePrtG zp#S4rx-nEDc!VIA{Xry)0!#GL420;B6JHNP#9^M$E(SfQKVBU$p;O>1+AM|Xtu!1z zYRL;;`3DhVIgbH;NTnk9YlRQ%htZe6zrb&RfZE#AZ>N9E;qCc> ze%YCcD>{pFb&XzBX*8j>L7q`D>1k-pC;;TR$^F|}FIbJ_jGbAmHcje;3e(KA6Rwae z3qI**R{MD&e*W9pjbZynpji>kH|MCMy=z1w3nm|m{bkwr?+@or`o+_1$IR&#SM@6F z3K%zFreU9n1A7jpA&0%Tn}rLJ+ix2aVp!wG)iRk&^6l9jV!rjhKu+A&RQYDL0$8fs zXVYk4rfR8?d5-Nj81NN|6WfQO3ZlQZWIX<@QL9F4iRY7%v;-*!W91kU%xQerlJ!?H zM(UMIl^xt9ggob}y`1`tdC`0_&Iip=U&{OHMJquqwz~(iy`0?pt$U$^nAif`n>URh z$6BzD%bb-n7G^sC<7DTN%Eex49ACLLweI)bY>scWvyx5AeLj4PraAp{_k&)v(93~W ze933yy=QJYhLlY!wRN)n-nKHT7Spm7hAWFy%VO_CO7V+3q~9$6t4>eu&p_WO znj*&L0>4UaB_u=($V($yzHz(qHE3KP^Eq5Jby)<{+-fscZa!A&cyTKjiE1bqYB;0F zI8&Msh(cR>K@QVqVQ(p?zDU1Z)!)9W#g}9WFh{8Fl?%>#@i9l=Y*=wU5|l4KM5>3S z{&Iu5j#_dMor}V_>v}KzV{iT5)=ym5*D8J3C)(#gPMTfOpuEPG*DHUr-$t&euZ&P1 zez&%MXTdX9u9(YMyXj!?*mmDMYx!XZ(_buGVV>($oa%P-!oNL}ozBFI9gZ1)u)Ngd z2uRh&bAM)8s$Tv*4=CRL5cpM>xL6Bq7j~k1cfmLnj%N-171^5lF4due?~98Y8;;xH z)X`*B2*{O2hQTG~KedtZsW!qlEmrtM_Hk(dgjl-eKy(Y|Z3Ki17x7 zP$UAP%`~2~<0LNw@R~|?lxsRvS+2Zf#X|_@IPhkP=ie`$=O2s$b$0E-^VSybRcJ(H z_zSQJLE1q4;i1X$Aa39Mx^D+TEf&V?t0d$&gFNN%naL)_JWcC7Cz){2kQKC!>RH%G zu3^Tm&1cD;{E-NhZ4@+YE_K1hua=Z-F~aoxt_BV}6C&=TTz@L&r97fS6isT1py_9ja2aRis|r!2>6>L0nalH7;y=@10%M7f>nA zUf;ro_-+|=E2tkA_PMrgej_S@z$MD?!+Tuapkxfq;H7QMpHkT-s9$S8S7}+RPRtpU zEq`LFzzCpA(`~vcmv;k$)Q8K7a}u>>N*-E!BC_HDX1 zik0Vs1H}$-qsA0c6VAI0bC|x{PJk)&BWJcz%_b8phQLjl!;?kgmzQIK$PqE=i5O{> z`IqavD+`4!9~V9Xr)sfZ$$RZy2Pg#auLB;Wx3N=1BH6`DK`Ul4^R*cL$n{`YVB{yx1u-mLhG^qy3wZv=`X0wjIZVae7nc#4b$ZHpX`Q( zcm;HC@>Pz>OCi=@wEmF1|KL;{PzJ~OzMSSJ`~@~vz>U{DqJfoZW4hWo7~=)WPiROE z{yu7KBnJ4g?^J9}`A!h;e8}nZ_}1wSB7e!z>C3^4dU4QuDTey%trM7cqa(cztvciJ zHSK+7!0$gzGkAolx0VxJ?=cN~ z$XJSJHA{klrBN%gG>6g3#g`L;pt1A$)Ep(IShg-FV-XDpd%p0%C}IPZKFk}}u^sm| z^3pFU{9#U=BbZ*pG;MF8837-cJ5?+ zY!k_hz&YprN_%Kzw}3tE4NrKy$(O%xo5+U7gh7~d5zgAhCVf@P{J|F*`rzynaHL07|q10Gst{O9-T$^709*KWgpeB0aF zj>8YH!aMMm0?8N~g2H}f8?OGLnwA9MwLu9*&^8CU(`t|-yl5! zRQu|&KYVd8?j@jhDfjay=rcRlZtZR9TK?FVYS8y-bWE9vRYC_|$^FhUkJ_>x@N3M* zQm@;#HW4ULXq0Lc#%tMD*z3~E4l3U}+%MU(xA6b7UoXmpj7-+Jv|Zu0ceWi7G~vVL zW$3(Het2N5Q4;lSW3N-Z*J@@-V(a$MFz-)W zZu4|r_~CC17Xq5GRpRYB{zH)RJig8tL$BJIi$|17XJi`Phf1*Id%cp+?Hu6MBT8l4 zhuyf1T4GD_?x>I79#c4U39c6IInLBB;ID9;KVBXGN?n^u7I<%lSppzg<5CN3Qn6f3#*mt5YO z^^C=?xWyg?#N=i$!_(zM`oKYRt=>wfj16!N^;$IFYdy;TabvH3g^%a5dV+^(ld0JF zw^b!3MlZAXU)T6NJby}DTUnr@WuUINHzUlzR&_DPMA9!kk>=T0K>d~a&QkI&_rO$^ zY8&*I7~O^p)x;Cmme84Ax?7f}R4?4Eu;<#dAC}xoaZ;eDja$6V&<(>K4fTMvXndJU zmIzupIQa==diK%enxrO}+X(vpT37Gyi;-}jBdWB5-I`gLn6ccAFccx`8W024=Qm+s zD3GaBH}VzAOpq?g@y|$kT~k%&Wm`FzP%cpJ08G|Y!_K<){_Y*4%P{qNv|JiL^Gy3* zp@KrVjty6HzSLA(qWmT&pXiHtB= zZ+VKK+@s9KFZy?#*s}n9J~Cuk6vK34rX|vwAI=wjZ6Q*YX)AL83_kH41unfM7l9i0 z?tL#0bDP!9#Fa1HWP5RX193Rvd8X7n!hFvn2LuH8PXeS&Sma+wnkNo!EzQh0Jxboj zO?Mn7ox2I)ow%0PPyORa%5)i+a|WKbl0{c8qpfhPG#)~JEv6UHO%hCBZ8E`c6kM0G z^jC#re+rhsLlCxWhmyIc9Q9;`&-&}FlSxJ<8;|Q*{zA~;;8Yt(@I6L z88p`ZdfqB}@-4iEPQ#js=7I(5wv~S)RomsJ-rv)4a~g*N5ihl{NZoX`u`zfk*Wia0 z7O`gkHx53!NWNv=ptRIxes-$6d%lH*Gb%eu9XC%tG6?L3NJnSiWw4^hesi2=)w#K; z>1Uj-iVEZQMU0TAx8tNiA`dAE8PO526y|5wsB=C9F4dfEX<>s`y4{CSnKV(EC}=)b z9Jo@()x``nr81R+!wScxS`@H~9VfSmPp~CWvQOJqPh(URWHD0DRPEi14;P!SYeVj_ z2RNg0ct{S$j#U(b?jvvDl=k1+!kAJI_ipoYR#z z*|FqShT43@->s9n>_R7i2VKfESjon8>Ijr^5be%czTC9{XK6C8)Hym+>G3O|Ow=TNP1+9}WfPH791~xm+%;GGY&gxFJ4?7|crKyuZ+76Qcmk<=b5L}!# zLU6HC?|s?BZQZO+*_id({f%WFY0L$YV*V ze&1H~)Jfn5pfk`uhb(do8$kXZBQ$m9wz*Hd!B5)9Kv)yI+t5ke}`D<o_7W-#T1S_h;CdHX#UKHTcCwFQpIphT^W{9W=z0xuO z-`nPv%NlS)ygh~QJkg74!O1D`R7*z`2vXTrXQ1rBrAW-Ejlk(k74C_2x(`n+TLe$ zXmQ!SF^iSHK6~{lAkK(EchY6T0tS>ej&Z(3dzH8bPMoCPD)*(ArwDg^ip62~#esq( zxYf&Gr_b~o*B=beNzaKj=IyFm;*e%N!P)u-s56xx4D{DZpAG^qpJAJI;WBQ!l}_Kq zFKNZ*fo4!iO?W7HbQjIlav031&2$JUGA^tvn*zgv?sQv+*0M}E7#ee>Mf=^BR&T*1 zUO|SVR?b>XTHYN>ILz3FmRD<|>VXA+RP;XW!K0<-hPDA%T&`HzV~mI1|0? z?qeb&HEa>3Z{?Hrt|3dn?T~`7ZEwDrq z4Fd-!{ue>_#I`T9f4`21=@p^*Z2a=FIY^_I5JmMEmg@eizi#e0z z{tFtZ2yu<4qse$l|HQWD@&A3(4tz=&qQ*2qPcYptfN9Yy+o5Bc@Lz~Kh^M~=)}5WETFk(K z%vp=NK`6D>mX@OuIz$KKChp11XHnUBLQI$Eoe{TGM4t>?Y(SLt&Ix+zR5p9;4KwXXpD}R)2 zj?-V}h7llkn8V#){};@c-;=(~8kMp$c5?We<0a@RY5d3a(6Cl_yStR+7v^(DsjAq) z>YqstLJGS^h6W#E5UcEdIj*C@ke$v&7tk{FG}7DaVDfMnBi+_0DIC}&`c6#4HdFt< z5%2BF@h$6nH{jGb#EyR!>8A5G2&QGhfsIAfDgWiFUW)9tW2NpW{2Fd4pfbPIq($pG z^uxAzhS=?7OyM!oME-FEnrUWY4$yGn&ZRWx@b_VHxh@=eY^(1QN_hPWAMhOpkqxSECp~!WdU3K@LE&vIkZF*YK(rrQi^Yu%1 zlm>{23CqafD82L3Pg0jbq9Jn0>RishJ%14ymcl6{{Nk-i_Oj`CfFvMy%9rBs>MS6` zecITO1GF9$60_XT@wMCR&DX(^wr6IOJ2{z35!7e_wd=M-n^^ArEV^~V>$*)Kv@=Gf z^2>lO3E-4-n1rh1TQ}b9*-rMy9PGwi&7Vm28ES|O0CKAEtLHxAgLrVZa;03QgQu+G z0GJ_BVh(aE1%50iC4r|fp;JVBb1<^9+F^bC{iYC-6fqVx5h=l_;0i5|-ALAqq1(Hjvl?Oge=$d3J-Q>*ng3G^Ko+;{9hL}7M zHhZNmi#GD9A(txcnQBtA%l-J;@L5h;N=thyUW2a_`Z6+P9jQDtI+x}}15$M-=-)m7G2wyt;~EFZ8gz?6zam83o7ez^@8T$PFT z{;@($_rp`khn_57ohnVH=@U@c2+&v`bE1U&7d=r@bMmPSfukFm?ZvCHajHe@Idt~Z z&dV;@&dJDt%RXV@NQ91?G4BVkSA2BhQdf#jO4Yd1bbS5h6xOk2o8eQI(v*D%-^tfZ zBTDr&^0-m520%2Qvh6WAZG)&i`?-kq+4x#-NxnV{cv(BbQ#{lbdc(` z6)9P8w)m9v5at+BervaEA=S|u9cHw5M#eNMX5ZVTCS;|KIC|hZ6SCPtzq0S|n(MqI zH15O?R0%D^Z(QcK>4QQ-l;*k+{)^*E6Y&Uu{Xp>&E2TH53EA`Ss~28p=@77qoKQX2H*R$LC^E{>w%~u zd%6p9g9S4~WSy|C9Xl74Y!wsvLGRPT`g{Q{Z?8kJ<@pp}VtUT2CBs#3eq2}Z+TtU> zVkPkVZMb*?sDCP{D{zg4ddmD`oZRvfId}3JOwli zQ?1HH4L@!?IPr1&UV!GJDJ+B^FRbQ*b2b9?oAs>sJ->aMD5;{ygG8t&WQJZmqjl6dpq&Di zf_`!*qcG;1#kzwp+7D9_uh;r|pH34fFE-UX-ghCgkp0XV*e$d-HtZW(22Cz1&Io>9$$j zf;Jm}eS)3|>X}dBD#uo=7aH*jgKo1@;X|DAlw;9|gQQR5U6RK9s1Mt_k)jNfT}KWj zDQ6wkm9iN&<-MhuqWUj6W~Hg2l+;-t?!JkN}~`Q((((N6Iy zRb}1ezC&U7v!&CBDRpHX8%qJ7Pc;M$K#3NiIWZ$3k3@7%}Te|1-y0#7%yco z%?i*~ff;kw!lbWyD6m-{r$X5V1F*A;iM0gt)a*X};g62pP6E zI6Dz_+(VG1vYLA8S1d$#ae319YRD~9mt~qL*DV`V&W(K*D*hcmtizn}gO2u?u_kXc z+~YEUAR+xV99@$qetA?Xc1xQYj`~N@*eGVEkZqpYX)A%x27x*=9e0g+dS>jEHM1w`iTFUaa703KylNOhz(Qg#FHxIj7Ce+fQ zVQ1~HW=RD)V`oA3Te(e=DYA0ZC`krBG` zv)R&e@Ne5c9kLQ(Jf0qRcA7@~g10Ve-F^aq=7#e_D-U%dy({bwxVJ~{mql&NO;5B& zj^=P9@nsU5MR#WUI{6ysE}hwGqx%fxSQs~aIs|uY7XUB?6+Yly^;MmU@T_73}fAi&Xv*ik6~c7?pgqIQ;&I^s|CbI2s1^pD2Z$D#P-mZ*arnpF0B}%WU^w z^a{c3zFH!@9PAM27Og7N#McI=iC3bD)q&s&eq`k&vZUOkFMIDe^iPh)@-Cl)WyH|} z0MDJegvHKDp(WJHmk#^suFO45XUh=W*H&5;mu2>9xLFXW8ZFQ>0EuA1FC2@w= zz{a*!Y+NBeSxED>h^u zv^b6cW?0l2B%*-vYX`_lL({B&W#aFqwuSuSGlk7M&ZSyVbdqAp3%c%{ap!kHt&(Fn z=)0cSsdVYa$*u(>`C{{}In%XOea$oiSZK)~C)WbSNh3@3GI!z?;^g4oKy%xFwgS)p zp&pbgUld{yvA2@LZH2#y(ok}K<|70oF)#lBJJfkG-SoH*O7;Ip=-xgD05l#DilyMHvf&wx(yYlk=${VE*h&w=gnq872VZL$Lm z{S&q5js0mCG2A`{I}ogjnbnu!Yq{C33hk)*_1IYYc)6TGv_5=seJ{3Wn?8unJlE;K z8wkqW3PJQxcA%%1#;EH`I|WU>#jI;OAH*LcnMaCtHgG21?DpSo-ziC5bFsUJ41?jc5cW`6d&s#+Omk$3fsnP#sXntyjWFsa<*R) z1sOmu(&PQDDh(k}aEc-R`Rr*MB#6)#9ct4o#NEk$D~xE8L@AJzlSi#1ttf! zbAA^miaIL-Z>o81zgx~ye)F)Xn+mik5f|cJA_1qh@8h-mJH9(&M^+>{+X3FkQmj=D z;(c{;P&{NOa6^8>^#)(xL}Fk~0wDeX*kxc5#_?**Sy(;u;{9qq6j;Yy^*lB%B2eJ9 z!sn%`P&R4D11qYbCs&a_xhhfe>OPqpGu_KHexM6fU(M2zC3BV?%NVEYX??4f#hI&I zsoD@VTQA~Z{Acvjyg?;435IHaz|AUO_`EDSh9&^3RfvPMCgr{^l=K&vQ|F&vmX&M4 zPQ}t>hrlV-poLtb>OC$=bbS~qX4ELhxh_xwOIP#Z^S*bkcHE>~s?#KCnJ;bjWY*{z zu-uEaOYR>w&~NIgH*VuJ+?#y^L~Bm)m|o|!zIpwVu&#bgf)k=RR_Y24owc)5Yiy@u zD(=MJS$)HxqB)$=9Qa(mgoS^{?$Gt|iys$iIlaEx2HHDj#h0U?)N#`%IsGmfmJ+5LR0ab8#l$&asdHMyaR10nGR<`-ET8=QoebBasp=Qkb?cS-`{ql7T2#cvZgivH(fLYq z@q5)k@kYZ;KU*nn4Q5izpj)!Em9H{D!EWBrU+&Li3hEvDW#1O^XuxE6X?_-auiqEW zF#5bZE2d^RRn*?q(APi;+e-5(jt<~gL*wZ2s;V+LYQ&cPp(rwx+6(SUY^R|2t5A3| zceH3v8a(F9OvRNkP#?(n9ZqBgRUM|ez^%RPRabNIDHfJSNJ5>eZC(RK=t6N0%bOn` z?~R#&GAr}4Wp9{r^PjVk-?na{gXDC%G3eM6jNhSGW~%(!vjinuC! zR+i1z$R41C{JIBaQBHw7GGzympZWo`h_dMao&e6qxaIKSwsTgqoLOIsR375ZOhW4f zl-cvgkh|6KLa%fR{#nXZEUI9eWuz4>@)q;FOgSD1ZjS)X)}DA z_FnC?%0TDuCKdky&uX$774xr|*;Qq^OQz{+UYQSa`NT#yyjHehS2|&OL6|ACH-<%` zZ>QA`gk1U1bByW~PxA3VeGNoFt810@Lx!}VE$X5*OefGRnc1;vBj!Eb{TIZp|3fXq z*xe5SalZNzkjrfou>S$`ls_LYMC(HwB@^Vwq5wuwYK5{{!x+wdE;yT$f_ztl{C zHE}!)jZi?5c8WbNDJ;%tq9=qkES^81pM^wr^z10j2H-~-XK)x{Owl!3HN&d>*v)8l zo{8=*qSE&e;Hb7Cf152C0w0gNZtU+Di^(_Jhb`qSgg{~JOx;yZbr#SiF z3lb9Y?@|UTG4eS<%YONh2PlLO3$h8G7C^3mo!fpQ)*H5wRHmn#eiINfzV|GX?D)wu z%lcCdO6Bjyi(5nU54|`Ny+%#d?dvCjY_9g{?@_XQsHFJ#-9@oWlqsmHsc8i0;l}Uy zKs@2Wq#QFLvFC)ew2EUD(U#rEQ&*YP+SjhA_L5l*Fe)5@bVARM_ebF1MoR1nY<&M{HRvn{>nBKPL++1$@(cJ6y5{FBt z4l7kgSif85NeG@!By1wo3c5^x2#&r?Rm)Go`=>Tsi^?~yj2Fa03(ctj+s1-Hj16;k zMg5T1K~yRP!;f3F-KDr#QGceC$WOcu`c_xW=lAeUdgUy$E;U(9l#7QRrLx^-5+Iae zqPy?2cX!>b`|#s){6|^C#qGaj66cF^uul-s#|M;4Z66y{_MWwuP-?`J=Yb?h4&`$< z-ph4QiTZk>UxqcnOP7>aa;M@F1G^=i0Rrh}WG4=aH-vo7Z;=P%1DLZVKkV2Ijw=zp z>NG3dH!0CWJV)Pc8Q5Z9XlagfU`;bmOE9Kyh@c{}wj z9U={E_=-+?HOCD?m3S~;t{aoUKyyH7UNGh>cyG_cYS(Fl{oko9YOOTg(ph(_S8Zks zG(?T?J6$kFrn__lynupnjgQSUPF_s*k!3x^{Pq_Udg+7@DYX@L+i{PlUOUzsMr4gr zkjq=X>mSZ6=i#QopF5>j%q#sD5B&UmeV^U1zwI%v4CEIq@0glS?H4vp5MS7S8lnCb z$2H%DX3P=>yIx`|g~!fhpZ;O+$HOSuWLR@?F8bVaGo{^_>dL~M?GMX6^z@93v}GMd zd;48TgSwAwu!e>G4A9Rj8O0+NvUNwQgbxob=<$6%|GhX0T{kZyIzE}tAKUfcqJp7^=jO%QX>_41`C97EX5eFU_Q+&FoHv%Z8YWLM74o%J;Y674Z(lz#db%?DKHP zqPP>@=UXQfw>&SG`e zb+&lfxF+L#Vb75Dm?ikh=4U7qcJ{ASco(<2`CEa~3@+*;%|8x&nQ<*W9=C?9%1g&! zs$mP8kysK6+`WqEm^L<)8(2WVMRqOxjD7B2_WU9eH6$0xy+(Aa8sLYbwH(rMHKtSV z5Hc_1>!ZJ{^G&PlkNM}iF<)OAUVss!oh@$}bacv_j zZY$J;-GY9frbHC?+uPV{yd$OeieTq&`gPY3Uqjr4OU1 ztY?tgv^mOqIYA8opz8}>Bk7q)r=eo*JM~7@YVs#f;)ON_l3_^7?3I z`CVvfy=22z=iH0XMiNFDZ0cZ34@Om!;7RhRaNI@2JL zALG+c*V&N0$T#h=62B3#%9VKUQWj~celSTn=kfHiedGR3qK#HUz-vLJAa(Tkkm{Vk z7H6#l!>KDHIKB-%<2oZv`5aA-jK9Kl@U=hfMw3B_hc&A0(UX3UBN z{pWGU=!d);)l7FIBIhjlBjB#}6<|oiV258^qecR(2xzyTk40xG=bLlo)UC;6VmsBD8h>K; z;H|Fy%KaOd_zz)=^=Oena(`(mK6#@@)^#2pSo4Q(mO2UfjPbV@^#)Fe8^*)WOjgP) z@9YpntjG=+(v7_kX5G8C{RJID@5|5+Q0py!|bVxAz+3FW_jMI{S zGF7XT%cYxv$>^idRRaw&3SKw7XrN`+M(G|iot&swO-exLW$Z2?bdu>b_7VX*>*Lis zfJFJ6%R&bY zNX+ueYF1EY?J*aEfi*;|&W)pFw)=yGru=}!LOt~=8S`-#!9f?&{wUojXV4rQ5>tsw zpc8oyNZlOlQgp~jqAlMH_>PA@YZ{(qPw((;DBKb838H&6`!pk6X#FfMR0m{}!*C?X z9ux97Q%Xadq;4r8)q#Y^QbNhvcQSF&AQ7smRl^32&L7>$6L2O$<}D7?V#YY-C9i&M z<^X;~1-=t_F>*WezN-qAbhD7w7F7tV_r%&{zHp0_sTNt^OU(NXVL93^v@mBW@AVHZY^4_d95um7N?vyK+^5tt`(}A%vn&=^BO_WK7Cu$A)u+|jPkL|zFSf-GMZCP(=EOqapao#F zhSOJnW5ck~)s|8dP6CwUSzuWeZ^OEJ?%1M?+!Q!{o+Er0+y#Zm1C+Z47{ z(27J?mAGrs^B|j5OoQzV2czp+_I*|CK8%s1ygg1lwmxhb6D;+4g-% zZU?FGCpU`vbw}W0H~o}*`!pWC-v#lLTA84QN87SKscMyrGCB^O@sS;MBJfjh45o?j z31~O#;8pM&yHKL^sP1zs7L^gA$b>b1RV_rTDhpa>9UE`eF-8ENW7|5@i44qX5UWIbwxbnOB+P2rVg2afR9GNXa(Yo+Z$A^r=16hHhFm{JKxxWlX=@)c*is zG@gkG(GEB>udMRUnV$Of#BiC6xGq!4)zL3cF-cdxYD?3~^tCS1_B>;sWvNwV{Ft4M z5$K~n+Jnr#+15Zaq5tZsKhIs%SU5ETi|54#=d0sz1NZRW&Da=g!(ACX8v$l55@hV$tZ~MBNv$IaN z0ugQ~dC#l zYPA*(UR*lsZ>O zDMTBUevAUNDs#fa*gn41C>0N4I(t1caWODIZDae5TUrniF|3;DbREfEvJzSTzS&k^T`DVHy>FmnCer+<&zJ|Di5 zPs1*xyf~KY@}#$#PQ~N#F4uk;jgGZ8p@AuqyZ7|WfE`YexBj~v9C z!VCv6W|R_7tK?@6zp-b?Wek|oEzd3IF(O>_Sw9IqWfrL0`z+3$Aq6#Tmt3(P%qm%x z#EH2OjILdrd0>qbK^JilFIsnJ>m>DZ&en@-!}s@-@gfR7)0o119KbOFV1tc}DB6!E2|Z%*POHL?f1KTrB=9nzmo;7@Yx1z45xc6StdPJAPAc-cc3b_ql>& z2(a-W%XMf^n$2Z_lDft?)#K#?P8V%vlm z)XYO3R&aFmUTNbyuqmrgzjpi%JLpie${ZPznBg#{6udoB*t3ZYhqf4=L!R))<+T~S zMY{Zz7FK@AxLPy?sQ{^y&}bIq)n_ud*7tuvua5J;g`vy%1T3~MeJ|iX|LY_|sEb>; zCz~$H+7o5VIb4}ET9TN4tIt9>Q#@$wVmN)$7hCgsz2Le$(^t& zkfw~-fP?c63jF!4YM)%P4Ss(8l}Bv8`qB3Gc15~?r@KkepB=-N`(_dV(Cna%frT*9YegI%ilcSHyz2lu#;^N2)4P0i;IiN zSUdqZ0qGn?JufQV7CS$05XIjc&2sf~`Ul5?`>YGoBmT3m|Mzu$@{7>Fgd=gebGs+l z;jh1~nwmQ&JS72+_73PCXFlWT^Umu>2j7%4Gc#{uZ6AM@c#*DGf_*tgnU4mqqLhUR zR8@kzC#LjcvIfE!tut7c0pm2JKqC9u6xbo^awJoq>5FXHc#L%U2i zzE98Iou*AV(gmZRY-XKH7&lp97$w6SC%?Cqydk!a3wyugpZr&jn@>%p>t$ZQ$&SBr zy6u$T81z1@*zw>`nA>+VMpyCK-(FZ4Vq+6i*eZx=biD@&;b%`e>gwnuHgkzBc<)nt z=8E5)r-(inhLD-eRmwpr)(;K!A*Y-xvxMMsbGzY&#rK zOauj8`?S_V{QTs=_tYtPoG-5o(39W2ZpwOSII3eKUvI2ju^pp;q%QF9qXis@Y5dLT z2=MqU4{DtWt@$c;?MJ2+stMN?QK`jc+bho8(pka|#iJUd=`6wtfq6?+(gvVwd z%yRzSZ9E9!>fhZ09t@hA(X(*EJ5+G7_L^2ulMH`sU2aQAFZJ-ca^r^RyyMgASy_SJ z!sI8G??K~5_-iU|X8PRhr!w~;_|$S&B(dQ z@Nq0r6`M$d2~OXRn59Q?c>Wcrm`+Qb2m*fUn;XCIu2KklGtXDqt zwHV?kjC$QnUpcPB=!zXzpQ}C(a0XpIarfM+hBoY0`PeO z5$&n2?i@f`uWIQeU$%q8P5{VU!VUidHs)dizVl&^Kj@T8C`% zZ=#X$a#DrTnWoYdeT157J6u;YD5hT!c?tD zDUnydnauXZKh3^Rm{_dUvN660e#+_)RVS3N9Yb!=?EDwep}9~KQ~*aFWUB<9gr(IDcMYwMjL!&jvjwfw9Z112W%zwxC~;zrJ#Yxr7=UZ0lvUG2HHB zaRaLKUEV&^D03NgN|-S~X%tTgTl0CAJY4~jQIws1hY71_K3w>$cXn?a z?J%!;99M8nRhWc?eV}W~S~!E(nwa=1xb)e@mJ+>W+S5@3-|R*1Vgjpasm`f~>fr>O z_Qogm&-69USV6U&5g4m{-gb0TSuZg=&5rjzHLbiDp-oj>Jbt04!;-srX)Pj46WM>P<&Efa891eppDMj zQd4}Hl@~?1%T)e!Y>2PmhqTLJ`G|(n=nq7BX)VppU=?fC!lQry5)!`io<4aW4=*zn zH{tpvB3ynVJfU3@egPZDX(Z=eA_w2x)@9%&Qc7&hnyZ76*`Nkpo@4#&(Dq4B<*1J0J*|jFkLQ@ZwSbzfs^FSZDhk}AJv`nfpkZG5otV#2VI6IiQ^s6^ZJ0o#Q`N;HA|Z(v6| zV5^ZZoTTWk^)fEIlo6>Ow-WbGanCcr!_#K;5?D@LE3U2vDo?fkxV%w-6hQ>t+E-o` z7zNHY&kZcxa8;RDv;HOO};jaPUXIMwet6%;DNb)*_>OPT%JJN&S_E0C49xU zeje4=7{=-N+qgC?)XW^tNQedoEm+igJZ20`W$$eY7xqaEvMSUsK(njZbk(`K+CBt+4iPg4-kNO>_rjn@on8gGz!EEiv<-W7qxYBusext_1ssZtYu^(y5q)6H?@r;$-ph%lN*_t&&33d$EOcKvsZBQlcaA0;M~ zk9)zV1zozmSqV`Y8c>2%aRKX{0ZTEF7eebt4C4yzWd)2={uNrSix&!N$S@@XO9WlQ z5?$ca4SR8(bwDU#L#;TyfZfmt)f^}1UJ7F~S)X;`u&t4^nSixm1@g&obIMNzT=uY} zs2zdOE4sn-L8I@N<@3UjclT0B`w3mSZNKiaJAV4^g@%xenUx-bLLvmX6bc*~%b?Im zSqa#^k<48l!pXSn^t!_1ItPXf8+$gr;lH`=S&-QjWgi@|X}P`#E_r^k$x&q77L3xn z+_ty_SeY7H9(p+KWa@exuuWu#{@yZG;3a17_q2b)f)ej;BO#5AL=6MN0~2wEcO4Vv zmH9LHuZ@mksh`{?oyl15i!i~fZV^8;yZ2Lc$Gr#vaZ+pJhW?zcobJsrx?~iRaB$VJ zWDb_WVno_}d3zn2bZUGD+5=s15pYVt;x9H*S^w4oR@`n@MEn6K7luM0_c|{Q99z>D z6ux~Z*>tLVwqqyh6Xqn+uxnQ{wc}zB1A~xUU7|tRAHx&@y!ba1lO;52MddpazOv(- z+0*UCitIro<0oaQ8 z(gh;-69jVg=VDfPjGUwHyk4B@vEzChaNetrV<~0@xI74~Pf(MuYDgCSSh&9{o0&7@ zgMj&k_9VayG%&Q^Ay*8ct(}E$3qKw5Fd7vVd8{J!7;nhzI*-G1w_o%-r$Twi3if1t zj})EUz8INLasGH4;QREQnw5nYy0l*A4+K5$sE;t4iIj3~5a1CZr@^|m1c#b;Dk*S! zWNxEB$xwqa$^aZZ;eo`20&z!^PSWU&Z)3~vJ_u_ej(YVN(7VP67hb}qTg+;7DW!>a zj{Z*3i7tKj-bv$$@qQB{4lvwk<}F}zq{%v7hx76SXHMcvpQepp&8}abqOkZ?i!1$N zf?0n@`|P}ts+oLMziics6ccc)@@{!mf%?H1Ogi~ME2{B_!unE?v%DfPoV%7xEgzku zl*I=VJG+kKr4y#P9c_jBvSZ#Ig>dQFPNP}`dgRVlHl?F3eKYPjIeU1VLS>G$E9Fcz z{N14`v$mkoKIn#TS`CusQKOE0wT|PqJX+N!FCqW%NNx2LcsAZ2L;t&qn{LJgs#mfR z!Mg?DjG8HgOC-2oi^k!C`Cuv3)DH2R*Nm2Kv^bcl?e2+rE)kwnH#6u?y^di}b3Hyo zk(7`Zz;cj7c52h31F74rLLV@5{ioXcL^pNu&$}-Jm-2h5zy3Ujmka4JaTO2h3u-Y; z-0)97UNNk+>GHhe-Y+(&%-xg@-`JfdFC`n1{sdma!*eo{M z&rKR-nxZug-I=+n8gzUzs!^tEZF^H#eA)J$iu-F(&4=;g54!e_x#1Q}HXw@x5Wkoe z&w|8?gKE600UK6O3AO3acR4Vg!~nZ$3u$SkEF~vdMqBzG?cG$-vR_aL(oiFySByl! zuCGbjS5LK?=#Z`+!bf#8gm2@&VICA1X3W%?Ax14${PS!=!8Z$SAIJ6@2?t1-5oZP@Jw4Ui#>cI^=6n@;TfuqJ z&Bgm6N;ViV^DTmc>^*rz;rZZ$P?M)6t<)sPI!?pO(SB2GVt1yaRQ`|o9+4^#wxVeQ zv<((WgX7f}i9%B-p~R~;$J}-M)PyZZ&4F~ZgW87Mw==wEq7dLD*i3ig4~*NP_g>~h2~@UUo)yo50~s+UG10(Ku?ok$NbRYwIwGtS%ikdp7kdd&4 zb|iDY1G!%tn=MqNShN%)Fuv4wY9*iQSF3ugtp7H8iJAU0x@h>c}L%u3pkA zZ}GV{ah@?mBP9G_Q7cia&a{0>2_8|J>m0W_{{0ycM+69;vvE~N7~EbahU(@{@mZ%J z=4qA89#Dqo@82LV5Y-4T@4jvlW$OE}wpld`0Y5-oA(I^ENSp_e7biY8_3DfZ%d!3a z5w^Z5MrJgcGsoYv&$dD*iWCr!5gTjgPwr>(#z>-=BE)x(Z?e2Qpi1ID)eg7Ccxy2f zCR?E0`+gIkv)g!5+iv5#z26?KKt}3|q6F=yUz7jNN;J#j79Lcq+Q^cBf6`5v>4f26 zvouYVnYoy5VK{176-m;ujnW_1k2*Eu43UOYSXf1J^NTuR_M&cy zbf>CfDjnL3*mkJ^7&MBtR}Y?~=>Hl>nD~1V<1`6YFmzARPr2;vb8vvPoUL+vR8g$= z>Iyt-9PU`T>J}UcLzfjr4h&`R@N>+@41g;;+FQejPzfLqV!@pctZqkXaNGi@`HySNK zT4--0-tvT{-l^d@Pi*dOQR{%sfKapWRn zL!Z>z9$)07HE+ur-F8RYq6s$`dd;4~x*<_Ab{v+0Dgs)ocjCd$7}A8q*Y=NHHd(3JmDaMB9hBmh=6?g2y^=S? zkW(4y{dhGz{T6?AGG&a>#`pl)gUE&0uS}@mAT~ht$Ad!lfFm zvvXVNWnKjhZ5f_BXxP0zrcQgH9!J;9#BDd(KvMAQ>Y}TI6_Oo>yUx*L!U1VCr&Kv* zIui@kCfr-Q7ZC=+9$GK+(j5@o-=&lDO_W-zw7*}n;z;TxPi)B zDqjb7;7*&(=BaQs8u#@XmekRFQf;b~px8ue$jL4As`ESg61Myq*<)&{w9}Pqxl`Bhj#VsAiqFk!?#4}yAjoqds5g9LDR7>SwkJv z8CR+H)lj|Sb}DR%d<$*rre5|cNA7_J?N~4LkN&c%i60v5QEAf94&2sWc<8ucREDXq zIPR6P_B^!80~fw#W`NulY^Dj+v&YqpI8b(DzgKn8$i>cjQiX)E6mX?DkB zu0L2o!L=42SmbrFBLTyZ-J2`DB5D+0KL5nA{pAQX8))~Uxu`Ew|69z)}HaiAYhk|%xM!_d+; zS9wW@*Tw7;D<+=1gAolrzJRl0GrzlSy@q-`fA5<+Z)(Y)g0y@QURTRjE$m1~xj2&% zrJ|T@mJY06%%1Y&Lo_bHL9IYn`%v&+;jpPXbptCM@OAIQxE)j^v<5kr%n?hNCTqJh z@#9EX;$@r2i(|_5u#<^|{TD;`1j$8(`KARy9NGCF+utq=woKNfd0ICY4K53nAFLs+3H4k*ROWLijYtxitJW?d?$8b zPTG@Fxn9E>5WXVM;JhO{!hHL-#jihkiKG(16?Z=OU(-PZLoE(&UKYVj>Jyl?EDjm< zbMPIMnlKlXy|89S>EgoWQ&oL);Bd4e0&0ZpPb?Jf;9R2ifkoTw*UlW-+UhUvGEMVk z9SAMJeUDF$O3$Abd@bAU$XE5RS6`cRl8Y2kZ|^AGlIDkh;7yShV`GDoi(|T8wB-*c z42AdPD4x=-pnWYTi*hX_#Fh#am}%g!={N> z6%luA%v^4j8j=;{rDMa+dn~e}semqqWim>e!(uoTZ3*M?Rr53?dh=I(?~S7|(7lTQ zJC9Zrv?W69S&WuG!fajqBJv z3o$F!mK*C6sOcKwW%)>~yhwTPe$ELI6Z4oZNySRz80Qmqc8%WIGDhg&+kY5rem?vC zf9Z~LRR^m3d1HB93pB(DO_r?bSwRCj#Z|pFhQRc7X0WjQf5ibHVCI24Jj|prd7Zrpbyb|o$O^h7XRO;t1 zlK;aqhWWJUD?Sbwl%#5TSL~tpq}r%XWiA^?Lc{{scMD&~g1vu<7`R(+ zKOBNwHOpU2)=rt^uJW^U#Z|(2(k$7q6R?k=hRj$H+0COxmS44gbX17+qQ!ascb=D7 zQ*SeE(X+fWJ3A<5R*NmLd((WGs@d%7TUqDmOx^x8U|3RX!I3FPU7d=#{k`Y#99}Xl zV?Xz2RTpo|jD#0?uD@S=xrBqEH+`mbJgSq>hy`bVXj?FR8uiApqMnKvE#l7H%^50Q)<#06LsHs|} zrKb(~Q-q|uNA9j)S!t)w*KBFN>q*%P^r3aI zRe8wL+vqwC|BNraJZ40~zgML&Yr+8BOx9&6y2>JR{%iE6JOD4ZyinuSk#X6-wE%1~ zVt=Ci%8hsG*%=3eGbOLTNYIO?Y1K?>om-iph^GWxH~eb(=69&HpnRVHR=cQ`3~UlM z_3CYofm_rswWu4L_gSduOTM%)hmvGMTFy}hGUvA388FYphCq||pqblEQR8KMJ@@@| zjA1g9z^bOt-Hrb=X^u}8#)zVTAHbyli!DbEqDxe?mTH{ehql~UkFPvBp3%$^}Q(N6gQ_i7Q z3$1Qtn%$edXZ+*UT~X>qaF2(1yTiC0SIIT72xwVHA4A^glsi!>gQ3ROuu_+O8Qbxr z3@~s6Emqf!jmce+2lMB7BI7dLfzmD>){}6g*d-$d_BHMm(Z9`7XAGRx_Z8Srcz@3J z|MDBf;m^^c-yQ@>EPXs3Z@r^2qd*aODg3|2tpV{(X*)I@{08prMmwqLoPi^WvFRZs zU5+zTszi+cP?&NomZ_y{ul=-4O~{E7_!iZwO{N13^%?mi)M9}EPU7WPc`6j75&*_l zbh^Y)S@&dVu^Y)Z`o(5{^Ow`6jdBvNHKI;r9b=3;eB4bCPR3JmOBr>|E1GAEB|?*H zz`CSEHzYKl0j8AS&zYIwpnhSWxnNNN8?KksJ1wg$y0=w7k4-|Chu?4C{%yE<@SxSrm)sfIPRr3-PA`ufg8fvbLIh2X1s)a<}d=<}xNg`nY z>OU7?al*wXJ8N#@uT!q~`r%XqMSK@CqY*l83OnbZY_f6dEt&0Z#G=l-j`0>gH+_#s zHFoS_nnV1(h7ng@-iw91SiHj4MESDQveVfT1eDTJ)C~e(7pseU)85SF$YJ%vTIh9V zMX%5otJZYsM_!P^T=!fN8YD3BuvXSMYrRq(5g{!OTW_Bc?Y|EdiFCeXBBg}FSQWi)s#zvdf(*nz4HtbSr?odErZdxtX9YDbY9wTf&5V)$p_^{x&{2T6~+( zcLANI?S|!AoCK4dF!cLCRvYVJ&PI%46l7<)Z~#ZKadbDQ{tbPDh_PWx&da8hxb+Uf|~b>E#s!YoDQa36AYo3W?mTEif;K#J6ct7+mL?^Hv_SP0W(R}7t|R}l41(Z{7O{9Dn%b=2F&A`K8$A( zy(;6C^mei#j{qSZGKnD2(hp;N_#XB1oczj6jPh2wB&dtil{;(ifYCLvwJ<|OU3gx@= zQgXWK(RYmq>f05K-<2%LNR1ZUuOQmm5PJGO$_&7iX!$vNLbkq8sE~HwwJ>h(X<^NH zg9agKsV4H}`l_8oxJhhpwn;$$!6$vtmuD~H_7$}xLOO^a9fx0&(P;e8o)6*h+Q7rM zEV*4WwMqwv{jG~gC;7>5t>0O7nR2Sq=98IkuJif#QTc`3Kk-ma+uK|71oGM?u!3DY z=rmAsL^c&+qVyb}Y9Ym)E{1^J;bm>qxwJpdQXm7Y`|3#rwZzxPq4NuCHI@u?b10}Y z?J_cANFP+-QruRR(UuSv9wrTkUZKIzTQU-VqFcEr;-J+|IYcW=PCaF18u00tHSPe{ zqtfA^5BfZeICW&^8|DnlU6kAKc-5;4?p8{79@(O?$H$Q~UaOqPwK^%p9!8Ik^*Q^6 zorgkA^(QM!G64F>g51@s0Bx70j*f3VC+BX#{=dw_^G&K=ue7m&O*2zLZQQKv=8Q=W zny}fRX@P8No@bhn{>TcgrcGI7fomi1xCP_x_FmaSw87AwjglykkdW(4EWyp-s26i%0L3`+GjfEdG^gOMYK?bksBDD33X@Qd%Ur8dFOidn`ea zQO3v7Moe8)1d?>fnCyseu8u+yTahxYDn20r@jH!DPS@MvB5*rN8Y3)V#|Dx&bnMXN zg=%0;#Z{Ns?r8~qfMA4b4TPj@!-8ZZV7VGI zW~0dBCR7Suqv#be7m+u*Bn}Z?-gNgW{YvG9e|(7-T263|cu@&JkrL#i10GL|js@=t z4S$#`YJ_SlgbU5&CR7yQ<2CL8S+l7UlY5hD4TDJN7eW}cf45CD3pXk~un2IO&as}qRy&}t1u|i)M-pti1Y8upq)b-1~!gYGrf{;B- zHF(7wf>AQFK}6!<#*eaL*MNYZ=wZ%P>p~3*ql`Z-tk;b;J;~b+4Z>pM_|V49b}2h< zB6ZY;+3d;(T3{+!8VAaLz@$Gtibmywe)ZpMzwV!2X+GrIC_B`mA=Ku#Ke$9+dpTgL ztSPie+m>m&=kv>EvuX)jLX=wIRbC8G{s-4clE`HjW=PmWpfpRTwR@tLr4}g_`_MM2 zN+7Kxu?eNi6^m$;0i%W`vsP^!Lzg-@JG-L5fPF5m%2KfA8?Le6uh~JeWYVsYSGK2J z`2ayv3Eo3#CE|B{mo88=H4&+?&gzVp4Mc6RVaCK5+eIR(7JaC4ZvK^+Nl^^XE>QL^ z*~|_>8?g`5jEJ8q;mw1o{FwCFhY#)VHM|1@NHWvgoGdoDx=y4jmQJ0}4c;!6avBhs z#rb(`W&s)1O3tNSs8bu7pMLw#k3O777>rbUF*sS zKA2Z~H0M3TH%F|PUh?DM@#`~hvwEH$&vD5m!3g6U`ZA``5tqWMLfWsnN&Jm>usz=8f!h*-?&iF)VtSxzpi!MpFqY6Hz2)1`nLawtwTPcz8Q^FQZ`% z`l{QRf%7p*Z6(RORh`EvwN;AAw>5fEbZGKQwOFxM@Oq~+VU7y$`jEeo0 zeYr4*0QT5-X@2EjNWQuI7&R`uv%0eV>;rj1a~<60%rS@QX65$MX6O6A7n-juwYN7X zmCwlGhC}dxcNcGS-Qi?a(4+0E>iTwzU{th|CZ8 zG7@DSMYRR^^=ifWyP{K9Yc;ofFhA3sC^w17u(Wu1@?hp1=Zf8$#gl26@_Ef#r>f>m zT6D=|FsK&qw36URP*If#ruvd#hBx>&hn%;4*{3lBhXUCXCo&cH`cwE zy=)DJXMA;4PzEUJ;nX9rlORbiB;hHXyD)-yPyS@8ooABS9J-CPo@?uIpX7Xyofjfb zZurCwC76A22ObWd;2kFPyq^QXt$AR5L>c#FuwPl_2!OSw^$u%~wAg6P@pK=#WD z2#Ef%{JZGNyq}vrsQ%wtfa|h_VCy!_QR2d9Ky_q-At%!ZBR3_p90pp)cL^r#Zw4Li zF)dSeh_GSfd{qva(qvcQGtEa18TbIUZx9%6}6BxP3FqQz4aEJ^!Rs>{q6FfAO(=@HrWC;bh84yE`h{-|ft%tr$5qdIDIpR~Jp+ zj|sY+)ejNaVmW9kC+cvt>v5@{xibg9db*!QTr}_T#6d|%zE{;YffGsB=aV8(Q=#(O zc^mnC=1XV;kaiNB6_o0?NiG4ORd2Sx$HXr&7!DTz1Z3gvEQ~O8!M?lDt1V?t;m=hJ zL_G9OXu9{qZ6%8M!9#JU<($tezx>S@y)D@qskEsX{8St+N471WU50#OG{cYMMvGTW z5`N59tkFIRvAOQ6gj#8%l54M;w_7d#1Q6`cIJ8P#gg)MA3fhkA+xz|)`M;#$7n4nl zOW|Ck!NH%`=`-!V4|;u?LzJASMpEU+CfKxrmedsd+eExVU+^2dp;|VLRL(LkkjYZ! zQlJ#3hlsYzPho8!Sr9qdqER|Jf{WJf0_2cu>FK?Q?vU0U&IEy5a^@RK_gl1xz97k-Hjh#+0 zzG6&pWB8t9q7u zQjZvEoAEKtNd+?%DXJR;=rUQ8{g!ZgUgrE*2$h+gum)Z&%;9>BJ{|3cV#6yJ0|Jyr z0mGY48#*7MOigm-ePBv$&`g(^d_@b{GVZ1#d?hRCtkL(GxT+9M1-5r&bsv66g*Mov z4R?rlH(}bX%cBr#HY)|5?0rUzuo}7=w6a#aW6-0lbz+LPL}Ku%#E%4T1IxQqBdcnVOVUYW`C>XiRJ5?I1 z{J4U+a3Jr<2htpXbX?@_RC?g*iR6_e0AU?73Mg+W0X0h9KH8SqM|!oFjN;$&LN64z zzT=ZQ;PAIwY9C8*NE)Nl4B1`6WKKG4&2h*X$m$xJiQj{sCS{dD?5;f}B>g#sod|_smaC+FV>%W7O;(`PCSe)STIG>aK65DPapwzWkJ($iK;m!=zYmNl5 zg!YrbUHbna+GPWzrc-wi@!)0U(NvWHXR^;S)Qj)8QwXno{@LeWls^`~XfrC@9`!#_ zp@h0;7O)HgT?10q1x9SH>IE3i{ph^`cSR_l8fg#red!;BULaC+=bR#OptN9$S-Zsy ztX^(wjShDoi23U`%<^9_W9&KAa&8v9AOV*82d)8R9NRvjzFDr}EKJUj;U<&rXmn@E*w@#8IsXUOr z%nWsg)#pPKYgcj*tEwHx9};PvC>>4C>*OeO4WGb=Y!9t5^mskYtvqgN;ABiobxG}$C?#B}?^_4qr$l(Ta`bb)gB7T z?b_hF3&Y>`_EC*K9u}HpzJ2J^M$MeDcy?3Ys{{s%ke=c&$uf-EJZ+TCJ$bM}ErQ_U zKOABk zcZ%P5y1yB1onAfWrzK)?ATIU|Mu5hz|6GI~t&J-cccQDUA>ckPG5grIrPv(Ec9 z0%g5hX5FHu5Gm%*N#uKGhD!`JWs8m(&jAN3 zP&|tvv$iP;@N*cula{FI=>{f$aUdhz*2|&QF2WG2zbQ{Wq12hlr_PwIDyf=Z z|F~;r4uLhO0yRnDV5%Lumn-TKsN8K2ok+uDc|v$u8e1f3Zd=_8LV{fGtKOe422wSv zd_z_|tQqTmQ0#Yc(Gwv=PUVtEk3@N!Slqw@fj0E6K-9#cb3+hF9Eh%n)44SZ!q`EAITDl8`{%+WX5S4p85!x9+M~|% z%X@C=iQ*rLpcJL-`t02LbaYVcMHSmHgr?8$tn48%Hx1&i&~ttc)HKL4)Wwd-BfP|+ zrZ(4M@g|3&un)uBz3D$UF`$lQo=q9XGc(AKHWC^=pNz~uJU@6Y4Lvk^C2R?LOinz@ z^u~i^Z8m$4BQ+J$o)<9-GwkIu@Vw7J8V=+Vtj5~RKVP@^x!-kmI)cf;GY0ybWl2I> zP>3sLpapGT8#+(sn5u!yx!c8be3fLz?WWXRX#ZZJ{IJI+i!uOu-NAmQ;#K{f5Fbl7 zCEeI(R%~vLbr|4c%!`q4s=YM-@w2>5f6D&*oXmg(s)+K!i390xE|oozt!T%3i>PIu zvJw-7x%SL|pU%&BP9Tu8iSQrAm0(R*6)Y;mh?oD$Xq>1){+RTz>MGpo&)i$ z%{*$DJxWvQmS^IBf`tq)WKBW zXG{Gh%9^wlU!bnp8Es8}C7tW;urcsGmV`hAztl!*eI4j_xI9_Q#3f3*5g^8Yo(!SR zU|wdr9uw5Q?|=Nq|G0^c6f{w`@qS}x0>FZ@gp;ej!?eb70Ki9=YJby8%Q>!^b(mW? zj+=^5b(s?-e3Yi4+ej;k?&mL6*yw|+9nuqT1>dV0D28$>%Qtsm%UYJ(p}%QOkjypI2_8D;F|0az zUtO>SkM#-vES&%)da0Bmb4mKdOR=J-2X(Blm~RdCOF84oh`fc_k_KZ^#(jHsu6E154 z%RpT+Pp6zYe>w70E#X&h{RR<(1vA_?#FyGEe*(jTM~uO!>~s%}tx)MUuwMDBS)ygW zzkkZgx}7IAVR?*xb~y@i^6eO;NgOTx?1U=FDN3oLBd8TppN#qm>@@^=8+0lFL!n+4#!0?*(VbzB`I!!(;NBdb;hfjaH~sy> z+wuqCcPyMm)lS~*TyB<)BHpxIn0p7W1v1Y2I3?10XG~n-jak)#WWwm8KWNk7NC1m; znOF`y3f9diw*+l!ma{vIsOl}M#0S{fbDV^}1AZ=_{1O!%vZwMzP9DR!iK&ihVC%W{ zQDr*Xfv3I4ue-qzzuAH1af$kWPD0(lGwnR#bOWz00uqGP+?x_DsmJdBNHeKGt^PTqyWL>oeRbFz%+arcA5m8ILjv<@?5$^IW^lVerGLPW16^n$KAGHj;w3 zGu8cF2;W{M@4~)XaF;nz%B6=S0(7^}YpZr!Hy_CYyGpauEDfggeu`Jq$yShi8IhB2 z2Q)*HeZV$}RHKRKgwy}c7+6kGOW*NU6~~Rhn_9ArY@h}J$El6y&{%a zWFCr3k>P38F57egwC3njdAst#!D;_dWMi5PhuOGt6}qQo#Ohr+{fMAJa~k@~yfLf( z*K4yG4`*xU#G$41I$)jFLP9lc?h1QosE_LO%Hn9SPB>DV(}XO1ghAtq4&$b-lFQvD z=F1tL*SM^d!nL$Ri5pBQqbNi+FFBE1)bCb<;kh&8idV=Ac zTF+11xQ*>dYMF76krHsHzvXPUv`+sV&3OKwafqisL?OXJWJ22=;6apb4gKDBQ>h1R;yd1iZ9g~ z6LKEfFIT5M$-}Hi?9V91S~)T)0#c9!`p!sLGh*t-TeOPh8cKfRb=oj;n`QY;_u+KpU^HJ6u2eWM8WOr(nm)&j|RraGu!jcfvm8R zqX6&2^rOM*x|>Ts*9n1-O)mr>_)8a%HZZKpX<*6GK9je*k8}tGnG@Q=X8>tuwsvityc7gROGHPQ%VR*U1tD!j1e8;i>URwlwob* zkraG_?eqS|#>^0x#bY~t7KLMYxMW0Yn0b@bA4mOm=;P&zLsK)R=IUxFR54zli6}ir zbMp_x+kZvE_&T?I(SPQ)f~5PMs;ceF$@}kU=A2SR$wjK00bkyb?B~*h%C=pH5}W4! z3HP?LFX&sZcG}piIC9)qCH^~+B$mX6v<=uaPr5d$69xpE0GcwZ? zNvQV_UEuFy(EgyUHc4<`x}2C5zR7fYbCJ%qIxdPRUm?3`CpVoc5Wote_b-2EkuWJ8 z?k@Mn<}tZskOv4p*H)oar@v3!t(iD4>1G1XJh@8O)yNwACff@ zb`o10Oq1?58(`4*S%@!Brs35uSn!1*Eck-urlAb9>Q!0Obj`)LU9(~QWrVpukg1oG z;Cd&!YsxP#*D}&1cQH#yqips?a5{2Lid-eI+Ol*PaR!0TRh{%ncixrUjOU|1>FlKS8Tlh<6>>*eG21j-2z3W~y0da#TdNYB*u9 zC=0u(ac~B!-Z6gXoxl5qOyiexo?Q(IB)Rs$pK2Y&R%RAic&j8!>?*S;#3GK)u@<8A zpRewnt@Bro4~khgy)_8lnFE=nnHl=xNJu_S6JL|mR2xKrS=Yp;K!D%dXq-n<|G}7m!x6ca%GMAGsa`kV zd}>xiNVBM=y4nFZjKmurO3KG2=?g20eKSOQsa-psbv1kxxW>bF*%O~VRqo$;(`xBz z3v<)9JT?7*+6|k&>wiFMEZZ7IyarNp*u}?8+Sojw$X9DwvTYfZW$a`AFbm}wNo>xi zGwRkm`l*z-nn1AnMoufAe6Ib&%Q_p<6(#~Z{gJL(5sj>p5~WK0njbqZJqGncUf#xy zQf&1M<)!*PcPV?v+ntC1r@gO^imGkj9U4jLP*55inxVU6knRo{K)O36Bm{{er5Qkh z0fsJ7LTLsNkXA|>krEL6ZQl3$&N}Oyzt4YXufJ9(os;y@Ce zSgf~u>TR8_N8_Q!bP>-i^cwymuG6%3PGiQbvB3LEjd$4zvX|aJBgkJlkJ2!-g{B=K zv4^NTcOvDp`jc7$9QF*P{OIfjG*r!aUS%$df3}NTv584^h(^7}bzyYpC!nWqL^*2J znX$*7vRJS_!~TA%tpwvxF;kyqBlvRNH)ouc577>s7?bd4iPU*Baniq~fs!=aev~yZ zMSdbwJHb#H7>GK5pSFhuqAYVR4q=tQJS61fm!+^jZtDtf<)})aGkj~tey{?lYP+Ab zy}A8hki*y^`Ut0^yu|J26Ch8RXHN;5R*Pv!>(+wXWTEm- zHD1g$&vZVYzd4Uq5*p)|I!6FkH{3#x6n|$_02m16y-b^YGK`j<#4>K_eIwqEJ?zx` z_QV*@rbuB%;4}k)*nMNf;HuY^Xa(C$v9B@mDI3=mJ?pl%^(h4Jes{zE#u(~m|Dv;D z6G*M6&QKx@Gfge8@;?g21f3G!uvl8cP5`&S&EwDGB<^?)!BX;S9-i*luEBGs=IsFJ_UWr4D$hp$?>OV zUYcyD{Mndw&$$9wAeZH_HK=D!(H0zwDa5aGG_!dPxEpn;1Nw04hEAIzP|mhKRvIs; z&dIM#w@19+;DR1fKuCN||H`SP*p*w2te+-hmsOTMQwl0JXCK&?bO$onV;gxq^pP}Azm8q7uE&bcw;wq(s}Mwcn$%!nX{m!d ze3xXm8#`It?HVC~d$e}ey?t@tciGic!kf)pIv(9);1n^e>|yW7eBV2ufLG!)UHiFd z5aUR)Ra#atYF}@ieK|dgam$;mF8tt|7N=7>k;BUeI6~6*z;tl$%H#}@SKghAhN!F_ z$gz}=?Un7j*Dpi5xL;VK$*jhGzVB?PUo)DM8m8#Xu1tE-gFH3SVrYjZy??W=TVTNq zTtYBE_4j(}$(yHp#qU&i?p( zq{j`QrJfR_B>mxn=q7)=SR+zB;Leei(rJUo-M@7rQDFNcs`j6RgQgWx7tj{O97ZuqkW<}ifS@N`G;O9FJt?nWDvZg_-7h5B z;%+;7r_}$+v??Qe?Xu<`kHbNi1|~?X48 z|EVlPHbgB494_A;7r={LjYYj@y1vU-~lBG?7NYI2)lcL)y|vK0XGtt5JMnMDw^&E&{DLo3e%r;y=|c zHs_|scYlI)8R_=nyNRf#;6q%q#M`7WYcoCf3o?)J-Dn^j(AfIk3hnfg_mI4Fd>(l zDP7HFqgt%4D+0;GBMD9$BXk{~4%T9MSV1pj2g7QSibOu3^WEu-OCzWI zi5U33d~BF5c5W4&a3TP=K*y&7}c-hQbE z`#$ZyUlJPyWN)Vap59*b_>Jc&s;b@b;Rw7*G5V@!p;Gsd_*YI*^Aui7bv64WYFOC^ zYr~)5k5xadkDoC5i7FOVg{qw--9pq~fXCU4X&}S^Hj}62A|orRdYz?-_?5vjAQ(cO z92An5h4SC`!~iuZW&;k21gC(Lsez=^hA)eS@pqZN;X50DdfuEQQyJg({VF)>dbG%A z1XqOu?Znu8E^VzToF}9RD4b9f`SgR!L(2{(-@mrs*wde^R7mlb4ihc>I<}==htcTJ zOUB!x)DfOu9pdU(G=eDG$dR-nn-qV6v>^wk6hu@`Weh@&6yRGNLJw(3ROMuDy&WMq zik;n5cq+5aWH!9e(1!y;lUM>Kd&qFj_W4!k7ip5uy{G#^Wr`;)damoYW3{z-;)d6b z!1hI+m|$nOS#UFHTN&}(^G_>dZbwMKjY`*9BNJr`Z=u-Pkn^-#M2)DPt-Y@NB;*E2N!`Y zleci6f~HIHSYE{x>e~uUwSzPzfy@m=_v2q!aZ(#2?qFgz#(>|iE=!`O6iX(C;@HyS zC@0RjxyAVuVT#h#-sLX2POo-{1n6<}a%d%VK^a zU+2X45clBU7HC_ZgZJGnREx&q`oKJr-C}v*7|#>v^l66MII$go#c}wR$a-_-uCmnC z78Q8EdaBBtT96;nH5pp{mD2#)Zgx`kv=@-Fa=(zYaCEHXD-u6GEe_-CywD0gs2tjL z8(R%3=U5gthnYuh1wXkeuf(zwUJM>n|F9Dp?w+Dz1aNgHe#tlXMHA&?e}ceoUl-)B z&l=8>S|D=iu2uBy&l{cA`ncD$xCu=qE=S%BWwvnhaNYCmWSuj{HD`KC$dY|{ArD3PMG7S9QP(Gk>BVous`w z+5dT@;+&iHzo`bAU$Xp2gd_F>VEb7@EEJ6;u{6}(gyF0Iv2_O@GJHR|RQ<$TWb)@9 z3J_#fS*Qo!?H*o(LbFD2;$a?7qg63lU%kKS{rk_@9Z~>AvIex#9S#nxsS|r}yqm}_ zGs=9}4?0zK9FSsmCQ=_Iu7P8KhS6@y7bS$HQtNn0uf$4I$BX_JS=dX)s1d%~MQ9v; z++7ZsW~-%naFB)3Iw$k3zVjw3VM6M7U$X(lceDTN)*W`SX(FhL-0}aI3djgD9)0WA z*>L?5{#)<6+eQ3&i-*~B$djF=o9b$#_D?XYVZW%jk*yyORGJU$nAq4k4iYpd`f1KY2(vv77aIr6s{YD0|yZ#_RUz{nR4zlrF`_-{5ub9%gjvPg-Yu&R( z%`14;GPONhjrQcT_`C z{9d+BSE4mov#ECLF8~tkGTpH(!B&d=p1_NPNA4BYW3N|A&0!6&ywE^s`F*)6`hUxWkVro)W|`cm9^s+M>2D=l3_MYdllGkr+!7hh`v4EQD_GtU!n zJ6r^Qtv)MI*_-sJp2LigNGVMca;5vvqWaQHVE8`dAOQP_lu) z5JD<7FY6{b8p9WmmDtXJ-1zv1eJXjsXeIh|Wr{X602G-!DUCiLymmm_`_8%2ajLv3 zC1-4x!-m^g(pFZhR!LV3OoGcWftTgtG%3u>W(4Nh_#-u(T3o{^oN-VQ_EIYtAehQu zs$~`&dY0Pq@4T+CsFjEBUwXshRN9+RwsweCowBO_(QYIs1!+e7!ag#@u9{}=d9Rnh zrTbfHBO6|XIwMMLRI^Z0;KqMi#v%|E@k@t)!jMadU0bTrA>jcvb`r-YU%s4h5ClD=7s@m+aORa|LGY3&fuj-`u5$ZYR0OAVWg!q z_4Kl+;!DS+A>C+Ui}?LD`XnQ#HT6YvehEc8nXiob&PJy_2O6qMGRd*TQ{C zA-ZYU)j?vVkfvS0lJ?Lx_47BMV%6rg2K+c+thBg9HYCC`Q0VByR6Y^mqW{+gAl6hm zk`a+_L^Lsm9b1V7N)i2p(~*3^9wrmEdFp)iCQy=5wT2K9lTr&POCp-9Ijk3mB8O}W zx}B9Ebol(w4Uy#O%1Y$wV*a0-7AyPQ+jXTUnsP;abg?3AtW4nHor$buhFa?itbwW4 zg}JuxCjqBW-V^wBSK!tkK>cx_DD(7_8kVaO+sg3@8{_C=9TRz9^#nG+4*JVVT;ta|!KWrUXiprqzUt)I3%{0G>?0J_&H!k0lvQ|@3DVVT-EJ@^I64|`-<$*X$K9C3 z>i5j4ToxTKim-DfB7hVZUa~MPNiHt_-MP6C@GBMd{rMTWbM{&CRk_cbqwYO^Ku_C` z-*)P6OOr3a&W*`hW_z&Nt)#JLo}H7DclM+#gFTtZ#7;2A1387A)<2!6viG2|;P*;e zZTHYB1aQE4aByTj+;7=;kc%K~k+M+bH$anp~%-Wf3eFYR~5JE~c=Z@>2Ujj5^E zF8@sJ?k+AaHt_;MH4rQ|XpI9@Mov?jdt4MjWt18f!+JD)$)E zl3I+62yjIvG&Dr`9MHe={;%Y@=XQ88wB+N#g9i_Mb+h9-PviR>tUwELWlJ8if2Ax( z0O{;tktFB4vhFTYee4c2Kh{q2W$53voyu&AguniK)TI{x{x|Y9vYnSOid1M@?aN*P z5w_FWS03vBmekZs3GUzq9`?H3KK{J(Lvjvzv;XmhUG0CdXmXM5n1GbMydIRIl9OO3_5}pkiSG8Vf36KX;5iKXEqfBm|03UTawP5^d*o4U0xX4h7pG0+DdxXQ z=KQip)JXvItzz%O=i;{i6!e_`I*}Kv+V*xVH&>u1LbzbusM=(0PZj`|CD{A~6B1fW zhWL9020HsuB|3nvBCF=0`RDa>P+Z(EO-{8)EICzR=*Yg{r4i8v7>Fk(AAmbHII;5x zu-_Q%J$w8Uumxq?!t&$fuuo}DO3(o6@V4NL7VW9x0FCF(UM=)QgzBSO`PsY1?1nQc>zu+fU{{{{ZP^A2$!>N@ufD(18nd-K@058f=aX}5EZ@S9 zP?R0@>)(sZyef5X>(f%lLPyabgO5YN`!Pq?@M`*d5%b z^2!{)fJF}oxa_d*Egp+~O6w+Rw9r;%)lPn8J8MBOszfTZCi~1;ee7ov-6t}{cEiu{ zo{vULQuJ}pf6o9ou-Z>|Hn?t{f~_g%h*d4JOdN7v?5zKq=ML0VF7Sw8QhnmgHEShh z@K|p*mApfj?d9x&r8EqxE%{INrdK_lHD*MCYD1cEry93+?f{p?@!Q>U*-=_GQ=ByP zTep!a?a6kzD($}b(8vZf8=+B2CFrG^u#yS&Fv_@kF9$nV5;BZnz`4owCu=HeYiW=N zq3J46lO2^6@z_9b^8^b6A=_$S9^hp%k<})LbIlfT1&tfZdMWWLLCPdu<>FS&GE~Zx z!?8oRRSf)hdYM(q)-H;ft_QW}i6|sMYxin+#3MJ;%alce`5?n^!})Df<7PG5ZuzO} zmzmyw-J*5wH998Ny50$kLp)_MufOSB;yYb{b|=>k8}Fi=8g;lGO2xJ-gD~(b+=2*% zHan&!DY#aOB+&t=%9wBmk%c(tHB^O0BR_pGL96-{B`0PkOlC%KK2kew1mQi3l z2ix*saOo?j!@-C7lH)f0KURa*iB0A4eX^pxdXx+j{hV*nI1iaA#)x01h-d%ucVig>7v$2M`6n9L~vD$&MRol>S%(32;`wI(Ef*d?iPN-aexPKdwkT?+DCs z=IWO-DG_{yA<-h-GQQXFNS>NN0`F!k$B7-?E(;5eUgkku@V|KbC($Eu(SL!VkGl1; z^2f7ft+_kslGJHvF_iIn&QM=#y$)7G!~EAh-dCp$-ehGgw<`NP;Cw8f2>Kk?6m*wRAbd zE2`xF*|nJlgjT*{LOa+RWc%t~bnC0t2ulr6aF69gDm4YKI2xvh8H|k9__|m zhEC;r&~HA`N$pEFFwPvZpd&IVb><4gTT`2-f2`DIDo5PZO<0s{cA$#Bm*9Lz>YKjj zbMiXduZd@R5dB87XJIpL@Zf&Nj1@?ZW~W*%TR`nuvm5_25mLatbAvYhx_*9c50oY` zF!;wbS!JhV2?!a97d-Nm*hxszX#UJl3+J1BJ`KyRp?qWs7c_P->SlJ%O1i_!)N2iH zl$u#B45wZD|Tjrt)JPoU`O8ebLZg5uDC^ip3SV zQ{yzwUu*7j?SQ1Z!zOTNzScX`^lEJFqo<&MJd{Pq;%e*j;vmb}eq;F`5L$84sbTTR zhj&LdgG+uX@Mdq)kI5~jeh~1f-FbMX%d~nPZ>D8RrGs~5q2z;zO7I^PcRv%gu1=GL%H`ROtUQe(D^NJ5T;WyFWYt-N1j-gFNa zb>2?}fvU=juq0|=1>gtNmuWbc248|yQ@5lha@ySA*+$YM_*;qK3v)G}3_m@3aS zr*{?H?Hq8*tZcUDR=@I&kG9JNPnuqhPpeF6!MA$QV70zF^tS~0s!m9LJOJ%~^KhG-R`3C#an&C{ z7VO@i4-0+z*}6_r_Vv~RZaA9v2LMfPOcC+y%dal0P)`YQL{LWGx=5a6Fe}$WIKz*3w`u#njfNou*GrOuH+pLX# zpu~+!XJKCm$`6^5>(tERm3kRE)@I_GTs6JtK}3c7u(78Rdg!D zHAS|40m0jpGt^olC`hJ{Ims3B(bqXPUjYfq2NrgYn^I#VBXrXYivf31=kG)`5NA9N z%xuO0Y$Xr5I?cDdn;$1pVRWDg&Kysr_1qudnu@TY2$X|>S-9;j{Bs`c4Nd9aR-ksEO1*L+&QV`;j< z{rO%U%Rp|f?y+;#qCs)GsrG|)=kph4V;J{mFQ9XzgRjTl9Se4 zc4}7xXUc*3)s0j}#xe#Hif`AB?4?7jeMhf3oRzYcW)H~E* zjK}&HNeRkAsXV#s%&^4T=`+|zJKK&HntC*DmLY-DtkqQW9TyES9S)RSkbm~AET9rO zf=X&Z)f;+^-kQvkjtT#m`u-#Y&i2C5L+g=%@usXpKhCF75o~v9vSWkFfRFl!bAn#? z`+3}MX#fWi?UE%`*Z4a`8xYHtk}n>qMC9bg&Uu`_>dJLAAKuL{t$$w&+R;;aaih?Q zZ=BA<0H_i#@mNVQwYEi@$x1w_LbN4YiNE6?oiN+MQr>-c*<7PA)-L!6Mphn}1As@T zr7BtcT7su{y#QOf6l>`nqNq{=6wI=ApO+a4gvzz=Z@0u&GdS27j&X-k+#b6^O~V8U zV}|7@V#YKGco^WD7LliKNT#JuOS24fNgMIQDn86j=re%2$-MC60qgezR+PKBiGUE) z;4$g?a6aN|M;<|8@Cjh=T-k=l$+Yji1k=V2_l?s~&h!Iux_4j32=d{SR1Ixq-XnYm zwV%HF<-l!AoS#E^swqQkt;js9i!_9ubKS*5y4ZDN0*Q_5ljLvM>EVUDQfVS@a-%Tn zm$fA0fBrl4Fr$z}G4B)(6{Z1aisOx_SjL*(7xy?O$nxfH#&_CsyyS%pP6dn5fec9v zc?$}#rEHM0%W{ncRRbm8S_*+}24~e67P!;F8 zo!=1*TY4oE1oYE!3EdcI2r- z)W4ZEDb&#+0CE>vH=xzh4DY5)79u6;5=c)Fx2TuX69LPh9v8El(Q_3G72d@?l-pKX zzZNrp_&`M-%t230w(Ayqb{GWWdW@bSD%oT|$PNNCshkh`x?KY5yN%${`lljTp&sVS zPZnrpFgD=kYm?nU@r7g?~JluUVmoRfrmK%_1sY7`LD3cb(`wim%ed zZOqhg!54?2pM2a!Gz&`2QRh98=p4=@Y1Fs;vwXixb!x_B)L`XsV*FUZ$daX<0)fgS z<&0GWhi9soUs0S0`r=TdP6dAYOOY6o@{2bG2l!fM^_I$@M(qh3AF@K$vYI!e4%$rU z_yeA5ViyuZq;HfjwHB@t{HRz4gt}Pb%^AP=?eE53;D)?wK8-TAk*Om$E7N3Lna1sv zSP@vnQ`&OLd36t$bAqaG%u@xdCHvJI2lSc*({lZOYq#5Hc-iOndu)2eNk9L*2?=&L zNA7tSedLUB5YO^bJ=YY|pkzWi19Z#%JeZ3+mfv&ypiz^I)3&@mvzshXPcM#xLb*i8 zK*yAiS$I}7b9$Uj-gKxdZqa123%ZDBET(s?`fZt^l7@DA9(vl*fZQUF;(*XkWyW2*& zO}{);xu^=1&%xEr>(_mf@ey67)zeFZvEPzi2Hjap-p3s~(i-i}xM>X6>J8m`-!OC+ z5AevUX?bx$57?N14A5vadtuYYHoO-1h{=FRyVRK#6}RKtHmbCa!k(x(YCyfpv>-Gs zCCS)kOl|y=ek87qKNfG5R+hh1kVANliQn;hq^Te!Iw*L!`PHkVuH(4eAKLK2+hZ%h z-7J0QQGm%)Xq$OcN+V@YABc(r+i}_;f$#h#w`sKF?1jnvuE3l^76`=3Qhk7a;heb$ z4r2#_)bM}=S<4qHB*rgA_J=RZg_-gKr^rP_Peb*qjdF})c`4-tt5GOdGrH-(Lyu?9 zGVRo;(e#Cq4!ue8SD4FX_s=v2Z`I<4X(%rsTiL$_>yw&XDcl2S?H!W4`9YEI@A7Xg z*MIc|Age0ll_Hs)*3}pJ+9xezFx9p$62UxW(CjmMt6w?`Py3#e87gj1WgnX2NM@4{$FEOyUwIc4!(_OI`VJ7h{w~NYLOUaFzp`;@ZclRr(Lz#hBl; zaY1s$fY+y3m_9L{E=|PJLC4-5zp@RR()lo+jeRtX1lgDgxi zSX-wAfhF)(Pa(wCOm+vc!P_V_}fCpWzD=`GVP6`k3UHM6j zpfHh4=OKsyu;6I9BxoT=@>WB%AJ;VbqP4RP2Uc$ub;-Gs5G+hrhiNpHCwDKTJ3`PAcop7L9@owxq7@7=wF;V=jO(-vIzOL6HhPx$Ik? z?i}`$Lw=?wAxFC9V#<5jvJjK2^OK2bF=D28Zb(h{R3{;?=Eq5N+9|%p07~$?G~lj2 zj0Q4`(NB3yNfV}v%j~xk^lJ)zvz$^Z+SOC5^f1NT5}>~GL?*$?f(cs>fw$ZJn?3yj|Lbq+tsvmW zQ`~><2c5=F5&@1sQ6@<|Acq_O-z?^0zWYUhqm`eQ@IRhX0Hlk5m*oBLbp;4K{r}&A vKKXyq4~c()+4uJ^Vs+gZ=wsZwse}cJuT0%bqa_Qw>veTyEhVIaP4xc&L&mCf literal 0 HcmV?d00001 diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 3e02a3e5c..1e942196d 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -190,3 +190,4 @@ 159. (원문) 1. 자동정렬 명칭을 방위배치로 변경(방위 배치 안보이고 테이블이 브라우져 폭을 다사용하게 반영 안됨) 2. 이제 연결작업을 할 생각임. 요소추가 버튼으로 생성할 수 있어야하지만 기본적으로 프로젝트의 사용하고 있는 구조물조합의 일위대가의 자재목록을 하나의 행으로 구성되어야함. - 초기에는 거래처와 주소가 지정되지 않은 컨테이너를 다이어그램에 배치해주고 사용자가 컨테이너의 모달로 접근하여 정보입력하게 됨. - 이후 선을 연결하여 정보 연결을 마무리함. 3. 이후 사용자는 단가산출 로직이나 마스터/회사/개인의 일위대가 사전조합을 연결한다. 이때 연결여부 표현을 위해 (구조물 집계표 측점 램프 그림) 항목 앞에 램프를 추가함. 4. 이 정보들은 모여서 B09의 운반공 하위의 항목으로 각 행들이 들어간다. 이때 B08에서 연결이 안되어 있더라도 설계내역서 행의 기능 중 연결 기능으로 단가산출이나 일위대가 사전조합을 연결할 수 있어야함. 5. 설계내역서/거리이정표의 마우스 우클릭의 기능 중 연결에서 여러정보들이 나오는데 이렇게 하지말고 모달창을 띄워서 보여주고 한번에 검색하거나 필터링하여 검색할수 있는 기능 부여. 이때 연결이라는 명칭보다는 단가연결 이라고 변경하여 표현 6. 연결은 중요한 문제니 너가 직접 조사 작업을 하여 계획서를 작성하고 작업을 이어서 해줘. 160. 추가로 단가연결을 할때는 구조물조합이나 일위대가 사전조합이라면 문제가 없겠지만 단가산출의 경우에는 연결할때 옵션값을 받아야하는 경우가 있을거야. 이때는 추가 모달창을 보여줘서 사용자가 선택할수 있게 반영해줘. 만약 내가 잘못알고 있는거라면 알아서 해주고 161. (원문) 다음은 B09에 신규 메인탭인 공기를 넣어주고 하위에 공기집계와 공기산출 탭을 만들어줘. 이때 공기관련내용은 초안이니 B09 폴더 내부에 draft폴더를 만들어서 코드를 작업해줘. 나중에 마음에 안들면 그냥 draft폴더를 삭제할거야. 일단 참고문서는 resources\knowledge\original\실무문서\01.산불거창(거창북상병곡)\01.내역서\00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx 파일 중에 공기집계와 공기산출 탭을 참고하면 될거야. 여기서 중요한 점은 참고를 보고 내용을 넣는게 중요한게 아니라 로직을 분석하고 어떻게 계산하는지 확인하고 구현하는게 중요한거야. 다시말하지만 참고 항목이 있다고 테이블을 만드는게 아니야. 우리 프로그램에 맞춰 적용하는게 중요한거야. 그래서 길게 생각하고 작업해도 되니까. 꼼꼼히 분석해서 반영해줘. 이건 현재 작업내용을 이어서 진행하면 됨. 나 쉬러 갈테니까 작업완료하고 내일 아침에 내가 아티팩트를 만들어줘. 일단 이번 구현에 대해서는 방향성 결정이 필요한 내용이 있다면 너가 결정해서 아티팩트로 알려줘. +162. (원문 · 10-11) 1. 거리이정표 - 설계내역서 운반공 연결 - 거리이정표의 항목행 하나당 하나씩 설계내역서의 운반공 하위의 노용으로 들어가야함. - 거리이정표와 설계내역서의 연결 후 선택된 항목에 대해 거리값을 입력받아야하는 경우 연결되거나 될 거리이정표의 거리와 연결되어야 하며 이때 아마 비포장과 포장이 다를것같은데 이부분에 대한 계산 절차를 보여주는 모달창이 있어야함. 이때 모달창에는 옵션값과 계산 절차를 같은 화면에서 보여주며 단가산출을 다른 항목으로 수정할수 있어야함. 단가연결이 안된경우와 된경우를 같은 모달창을 사용하면 좋을것같아. 2. 거리이정표의 하위탭중 항목별 경로는 나타내는 하위탭에서 거래처/기준점/시점을 옮긴다면 주로를 옮겨서 업데이트 3. 거리이정표의 시점에서 나오는 모달창의 중점 측점은 km 단위로 변경, 하위의 설명은 삭제, 맵은 공통 카카오맵으로 변경 4. 거리이정표의 다이어그램에서 항목의 폰트를 거래처의 폰트와 동일하게 변경, 아래 주소가 길경우 자동으로 줄변경하고 컨테이너 세로 높이 증가, 방위 배치나 기본 맵연결에 따라 위치를 잡을때 거리에 따른 선길이가 지정되면 좋겠음. 단 너무 널리떠러지면 안됨. 선, 위의 텍스트 중 포장타입과 거리는 2행 구조로 변경, 거래처 및 기준점에 대한 모달창에서 제목의 폰트사이지가 내용보다 작음, 소재지-주소찯기.지도에서 고르기(...) 항목은 삭제, 기준점의 모달창인 경유 기준점의 이름이 2행구조로 됨. , 주소 및 맵 영역은 상단에 가로선을 추가하여 구분할것. 5. 기준점의 다이어그램 컨테이너에도 주소 추가 6. 거리이정표의 다이어그램에서 선연결의 드래그하는 원을 박스의 좌측 상단에 배치, 단 테두리를 넘어가면 안됨. 추가로 컨테이너의 내부에 작은 주소를 찾은 그림을 캡쳐하여 프로젝트 영구저장소에 넣어주고 컨테이너 내부에 배치 이때 그림을 다른 곳에서도 사용할 예정임. 잘 보관할 것. 7. 거리이정표의 하위 탭의 경로보기 기능 중 그림을 캡쳐하여 프로젝트 영구저장소에 넣어줄것. 프론트엔드 기능변경이 아님. 그림은 나중에 다른곳에 사용할 예정 8. 거리이정표의 다이어그램을 누렀을때나오는 제목에 테이블 제목 행의 정보가 들어오는데 삭제. 9. 거리이정표의 테이블이 컨테이너가 다이어그램의 영역과 동일하게 브라우져의 폭에 맞춰 변경이 되지만 테이블은 맞춤이 안됨. 혹시 테이블 양식과 충돌이 있다면 알려줄것. 10. 거리이정표의 기준점은 2개 이상일 수도 있을것같음. 이럴경우를 대비하여 선연결 기능을 기준점에도 추가 11. B09 총괄설계내역서와 설계내역서를 하위탭으로 이동하고 상위 탭으로 설계내역 탭을 생성 12. B09 단가연결 열의 정보가 텍스트로 구현되어있음. 랩프로 변경(램프양식은 B08의 구조물집계표에 있음), 선택 기능은 유지, 단가연결 열 제목은 단가 > 줄바꿈 > 연결 로 변경하고 테이블 열 폭을 줄일것. 13. B09와 테이블 행선택에서 페이지이동 기능이 구현된 행의 경우 붉은색 박스 하이라이트가 반영안된게 있음. 14. 하이라이트 기능과 연결하여 조심할 부분은 셀에 수기 입력이 가능한 영역을 셀테두리로 표기한게 없어졌음. 조심 테두리의 값을 조금 더 옅게 표현. 15. 자재단가대비표에 선택한 적용단가인 경우에 해당 셀에 색상을 변경했는데 이걸 테두리를 넣는걸로 대체. 이때 입력셀 테두리와 혼돈이 될수 있으니 녹색계열로 변경 16. 자재단가대비표에서 최저가 일지라도 적용단가를 변경해야할 경우가 있음. 마우스 우클릭에 항목을 적용단가 적용 항목 변경 17. 설계내역서의 비고 열에 빈 칸XX 정보는 삭제. 18. 총괄설계내역서/공사원가계산서의 제일하단의 총공사비는 하단고정으로 변경. 양식은 테이블 양식 확인 19. B06의 횡단도에서 상세버튼 모당창의 내용 중 나눠진 면적지정에 대한 기능 구현안됨. 기준은 현재 선들이 교차가 되면 선 요소를 분할하였음. (암경계선도 분할 필요) 이렇게 됨면 오픈된 영역을 제외하면 먼저 지정도 가능해야함. 이때 선택된 면적은 선을 지정하였을때 길이관련 정보(기능 누락됨)보여주는 정보들처럼 면적 정보들을 보여주면 됨.(이때 테이블을 선택하면 그래프에서 선이나 면적을 지정하게 될텐데 이때도 길이나 면적이 보여야함. ) 20. 횡단도 상세버튼 모달창 테이블의 항목들을 선택하면 테이블 하이라이트가 되는데 여기서 셀과 셀 제목을 지정해주는건 좋은데 중복되는 선때문에 보기가 안좋음. 21. 횡단도 상세버튼 모달창에서의 그래프 영역하단은 텍스트 입력창으로 별도의 정보를 B07로 전달되어야하는데 내가 원하는 방향은(그림_162-21_횡단배수_글.png — 배수관 원 표기 · NO.71+13 · 관보호공 · 관유입부보강 글) 구조물이 없는 경우에는 당연히 빈값이어야하고 구조물 중 횡단배수 구조물에 대해서만 미리 넣는 스크립트를 만들어주면 됨. 이때 이질감이 있을수 있으니 텍스트창이아니라 캐드창을 보여주는데 그냥 배경만 있는 캐드창이기만 하면됨. 그럼 다음B07에 테이블 상단에 배치하면 이질감이 없을것같아. 기본적으로 설계자들은 대부분 캐드 단축키를 알고 있으니 빈화면의 캐드화면이라도 문제 없어보임. 22. B05/B06 공통 좌측 패널의 구조물 조합 지정 기능에서 미지정 항목에 대해 노란새 색상이 좋기는 한데 눈에 너무 띄는것같음. 이건 구조물리스트의 램프와 같은 색상으로 가는게 좋겠어. 회색이 될것같네. 23. 이제까지는 너가 직접 코드작업을 요청했지만 이제 폭넓은 부분에 코드작업을 해야하니 너가 조사작업을 하고 계획서를 작성하고 브레인으로써 다른워크트리에게 작업을 지시해줘. 그리고 완료시 너가 작업이 누락된게 없는지 확인해줘. 코더는 자기가 작업한 코드만 검증을 하고 화면 검증은 작업완료시 내가 진행할께. 너도 필요하다고 판단되면 브라우져 검증진행해도 됨. From c172c4a4621f15c7a1e3ad236f30238c0a1cba51 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:40:02 +0900 Subject: [PATCH 031/151] =?UTF-8?q?chore(plan):=20=EC=9A=94=EA=B5=AC=20162?= =?UTF-8?q?=20=EC=A1=B0=EC=82=AC=20AJ=20=C2=B7=20250~252=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-11_plan_250-252.md | 26 +++++++ docs/raw/요구_1010_2/조사_AJ_162묶음.md | 91 +++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/raw/plans/2026-10-11_plan_250-252.md create mode 100644 docs/raw/요구_1010_2/조사_AJ_162묶음.md diff --git a/docs/raw/plans/2026-10-11_plan_250-252.md b/docs/raw/plans/2026-10-11_plan_250-252.md new file mode 100644 index 000000000..5d346ddea --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_250-252.md @@ -0,0 +1,26 @@ +# 2026-10-11 이관 — 250 · 251 · 252 + +## 250. B08 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 줌 · 지도 높이 · 도로 구분 = 선 자리 (브레인 직접) +정함 = 사용자 149 · 150 · 152번 · 「이번부터는 너가 직접 작업」 +- [x] 250-1 「고침」 → 「수정」 · 등록 모달 [+ 줄] 없앰(상자 하나 = 항목 하나 · 같은 이름 합침 없음) · 기준위치 = 항목 · 목적지 없음 · 페이지 폭 전체(다이어그램 · 표) · 다이어그램 틀 높이 고정 + 틀 비율 그림 범위 + 처음 배율 = 상자 범위 맞춤(사용자 줌 뒤엔 그 배율) · 카카오 지도 높이 어디서나 480px · 도로 구분 = 선 자리(거래처 → 기준점 2차 · 기준점 → 시점 1차 · 그 밖 포장 · 비포장만 고름 · 파이썬 · TS 짝) — 검증완료(test_250_1 4 + 244_* 63 · 249_1 통과 · typecheck · 화면 = 글자 높이 15.8 → 49(배율 1.59) · 표 폭 = 틀 폭) +- [x] 250-2 (153번) 카카오 지도 = 처음 한 번만 맞춤 · 소재지 · 기준점 고르기 · 끌기 뒤 보기(중심 · 줌) 그대로(보기 밖 표지만 그쪽으로 옮김) · 상자 색(공사지 = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 테마 변수 옅은 채움 + 테두리) · 거래처 · 기준점 상자 끌어 옮김(자리 = 데이터 pos · [저장] 때 서버 · 기준점 옮기면 거친 거래처 따라감) + [자동 정렬](끌어 놓은 자리 지움) — 검증완료(test_250_1 6 · 244_* 통과 · typecheck · 화면 = 끌기 -45,40 이동 · 끌고 난 누름 모달 안 뜸 · 밝음 · 어두움 색 확인) +- [x] 250-3 (151 · 154번) 선 연결 = 연결 점 ● 끌기 — 거래처 상자 오른쪽 가운데 ● 를 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 km 비움 + 길찾기 한 번(km · 경로 그림) · 등록 모달 「기준점 거침」 체크 없앰 — 검증완료(test_250_1 7 · 244_* 통과 · typecheck · 화면 = 공사지에 놓음 → 포장 · 카카오 「도착 주변 도로 없음」 안내 / 기준점에 다시 놓음 → 2차포장 10.5 km 자동) +- [x] 250-4 (155번) 공사지 · 기준점 [+] 없앰 → 다이어그램 왼쪽 위 [요소 추가]([자동 정렬] 오른쪽) · 등록 = 상자만(새 거래처 = 선 없음 · linked false · 연결 점 ● 로 이음) · 연결선 모달 [수기 입력](카카오 길찾기 연결 끊음 · 자동 값 지움 · 길찾기 · 지도 숨김 · 풀면 다시 길찾기 · manual) · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝) · 서버 모델 linked · manual — 검증완료(test_250_1 8 · 244_* 통과 · typecheck · 화면 = 요소 추가 → 선 없는 상자 → ● 를 기준점에 놓아 선 생김 → 연결선 모달 수기 입력 켬 = 길찾기 · 지도 숨김) +- [x] 250-5 (156번) 거래처 경로 하위 탭 = 그림만 — [다음 지도에서 길찾기] 새 창 · 탭 안 카카오 지도 칸 없앰(길찾기 · 지도 = 연결선 모달 · 그림 = 서버 자동 캡처 · 올리기 · 되돌리기 · 지우기는 그대로) · 쓰이지 않게 된 routeMapView · kakaoRouteUrl · routePoints 지움 — 검증완료(244_* · 250_1 74 통과 · typecheck · 옛 경로 지도 시험 = 같은 SDK 싣기 · 안내 길(editMap)로 바꿈) +- [x] 250-6 (157번) [자동 정렬] · [요소 추가] = 다이어그램 왼쪽 아래 · 메인 탭 크기 단추 · 거래처 경로 탭 = 머리 줄(거래처 이름 → 목적지 · 주소 왼 · [그림 변경] · [초기화] 오른 · 글자 = 메인 탭 크기) + 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림 올리면 그 그림 · [초기화] = 사용자 그림 지우고 지도로 · [그림 지우기] · 안내 줄 없앰 — 검증완료(test_250_1 10 · 244_* · 249_1 통과 · typecheck · 화면 = 단추 높이 32 = 메인 탭 32 · 경로 탭 지도 · 글자 14px) + +## 251. 거리이정표 운반 행 · 단가연결 (브레인 직접 · 사용자 159 · 160번 · 조사 AI) +근거 = `docs/raw/요구_1010_2/조사_AI_단가연결.md` 2장 정함 +- [x] 251-1 운반 묶음 함수(B08_Quantity_Haul · 총괄자재 → 묶음 행 · 장비 두 행 · 옛 키 haul:1~6 유지) + B09 운반 줄이 이 함수로 — 검증완료(test_251_1 2 · 145_3 · 182 · 184_2 · 239_1 통과) +- [x] 251-2 거리이정표 서버 읽기 = 운반 행마다 상자 자동(with_haul_routes · 거래처 · 주소 빈칸 · 선 없음 · haul 키 · 저장은 [저장] 때) · 서버 모델 haul · ref — 검증완료(test_251_1 4 · 244_1 · 244_7 통과 · 실제 GET = 운반 여섯 상자 붙음) +- [x] 251-3 단가연결 모달(openPriceLink · openLogicPicker 바탕 · 종류 고르기 · 구조물 조합 = 항목 목록 · 호표 줄 · 단가산출 옵션 창 askInputs) — 검증완료(182 · 204 · 246 · 184_3 · 166 · 208_1 · m02 통과) +- [x] 251-4 거리이정표 램프(항목 칸 · 호버 = 무엇) · 표 우클릭 [단가연결] · [단가연결 풀기] · route.ref 저장 · 다이어그램 틀 높이 바뀜 = 다시 그림(아래 잘림) — 검증완료(test_251_1 7 · 244 · 250 통과 · ORCA = 모래 줄 우클릭 → 단가산출 고름 → 옵션 창 → 초록 램프 → [저장] → B09 모래운반 단가 2,002 · 데이터 되돌림) · 상자 모달 [단가연결] = 안 넣음(우클릭으로 충분) +- [x] 251-5 B09 운반 줄 ref = 거리이정표 ref(haul_refs · 설계내역서 덮개가 이김) · hand_sources · used_folders · hand_resource_keys = ref_rows 한 곳 · 자동 줄 단가 = 줄 키(Py · TS 짝) — 검증완료(test_251_1 6 · B09 시험 98 통과 · 145_5 166-5 안내 글 = 236-1 로 이미 깨짐 · 실제 GET 200) · 운반거리 km 자동 채움 = 미룸(로직마다 입력 이름 다름) +- [x] 251-6 B09 설계내역서 우클릭 · 「단가연결 ▾」 = 단가연결 모달 · 이름 「단가연결」 — 검증완료(시험 · ORCA 메뉴 = 항목 추가 · 단가연결 · 로직 입력값 고치기 · 단가연결 풀기) + +## 252. B09 공기 초안 — 메인 탭 「공기」 · 하위 「공기집계」 · 「공기산출」 (브레인 직접 · 사용자 161번) +근거 = 실무 설계서 셋의 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) · 산림자원법 시행규칙 별표2 파.(2)(타) 「공사기간 산정기준근거」 · 건설기술진흥법 시행령 66조의2 · 정함 = 브레인(사용자 위임) — 아티팩트로 알림 +코드 = `B09_Estimation/draft/` 한 폴더(지우면 탭 · 저장 칸 · 서버 셈이 함께 사라짐 · 본 코드 훅은 없으면 건너뜀) +- [x] 252-1 셈 짝(Py · TS) — 공기산출 = 설계내역서 항목 줄마다 시공량 ÷ 일작업량(장비 = 일 작업시간 × 시간당 작업량 Q × 대수 · Q = 1 ÷ 단위당 주 장비 시간 / 인력 = 투입 인원 ÷ 단위당 인 / 입력 = 사용자 값) × 반영률 → ROUNDUP 1 · 소계 ROUND 0 + 준비(서류 · 공종별) + 기타 + 정리 · 공기집계 = 착공 연월부터 달마다 달력 − (기후 A + 공휴일 B − A×B/달력) ROUNDDOWN → 작업가능일 · 준비 → 작업 → 정리 차례로 채움 · 마지막 달 비작업 = 쓴 비율 ROUNDUP · 공사기간 = 작업 + 비작업 · 준공 예정일 — 검증완료(test_252_1 8 · 거창 공기산출 87 · 총 95일 · 작업가능일 23 · 23 · 21 · 21 · 16 재현 · TS 거울 같음) · 반영률 0 = 일작업량 없어도 0일 +- [x] 252-2 서버 원천 — 줄마다 단위당 자원(역전개 `expand` 1 단위 · 주 장비 이름 · 시간 · 인 합) · 공휴일 표(일요일 + 법정 · 대체 · 선거일 2024~2040) · 기후 기본값(거창) · 사용자 값 schedule 다듬기 · 서버 정본 셈 — 검증완료(test_252_1 · 실제 PUT = 화면과 같은 223일 · 준공 2027-11-09 · 값 되돌림 · draft 없는 꼴 흉내 = 훅 건너뜀) +- [x] 252-3 화면 — 위 탭 「공기」 · 아래 탭 공기집계 · 공기산출 · 고칠 칸(착공 연월 · 강수 · 혹서 · 공휴일 덮개 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 준비 · 기타 · 정리) · 공휴일 칸 풍선 = 그 달 공휴일 이름 · 사용자 덮개 칸 = 고친 칸 색 — 검증완료(typecheck · B09 시험 196 통과(145_5 = 옛 깨짐) · ORCA = 일작업량 · 반영률 · 기타 · 착공 · 공휴일 · 강수 고침 → 즉시 다시 셈 · 캐시 비움) diff --git a/docs/raw/요구_1010_2/조사_AJ_162묶음.md b/docs/raw/요구_1010_2/조사_AJ_162묶음.md new file mode 100644 index 000000000..985fd868b --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AJ_162묶음.md @@ -0,0 +1,91 @@ +# 조사 AJ — 요구 162 묶음(23) 지금 상태 · 정함(브레인) · 담당 + +근거 = 조사 에이전트 넷(10-11) · 줄 번호는 조사 시점 기준. + +## 1. 거리이정표 → 설계내역서 운반공 · 운반 단가 모달 (253 · sub2) + +지금 +- B09 운반 줄 = 총괄자재 묶음(`B08_Quantity_Haul.haul_groups`) · 거리이정표 [요소 추가] 상자는 내역 줄이 없음. +- 운반 로직은 거리를 직접 안 받음 — `GF000134~136`(골재운반 덤프) = `Cm`(분) 손 입력 · `GF000137`(트레일러) = `t2` · `GF000283`(자재 운반) = `사이클시간` · `GF000284`(산림 덤프운반) = `운반거리`(km 하나 · 임내 속도 5 · 6) · 불도저 = 거리 없음. +- 실무 설계서(거창 25 `단가산출` 1308~1347행 · 혼합골재 운반 덤프 15톤) = L1 포장 25.1 · L2 비포장 0.8 · V1 30 · V2 35 · V3 10 · V4 15 · t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4)×60 = 101.23 · Cm = 5.33 + 101.23 + 0.8 + 0.42 + 0.5 + 1.5 = 109.78. 경쟁사(STmate)도 같은 식. +- 근거 원문 = 건설품셈 8-2-8 덤프트럭(`resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/8-02-08_덤프트럭.md` · t2 = 운반거리/적재속도 + 운반거리/공차속도 · 도로 상태별 속도 표 · t3~t6) · 표는 master_data `계수_건설품셈_08장_건설기계.json` CC000054~058(로직키 없음 · 읽기만). +- 계산 절차 글 = 서버만(`B09_Estimation_Sangeun_Text.render_sangeun_text`) · 브라우저는 로직을 못 셈 · 옵션 창(`openLaborInput`)은 입력만. + +정함(브레인) +- 거리이정표 행 하나 = 운반공 줄 하나. 자동 상자 = 옛 키 그대로(`haul:1~6` · `haul:{묶음}` · 수량 = 총괄자재) · 사용자 상자 = `haul:r-{경로 id}` · 이름 = 항목 + 「운반」 · 수량 빈칸(설계내역서에서 입력). +- L1(포장) = 그 행 포장 계(2차 + 1차 + 포장) · L2(비포장) = 그 행 비포장 — `compute_milestone` 결과. +- 사이클시간 = 건설품셈 8-2-8: cm = t1 + t2 + t3 + t4 + t5 + t6 · t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · 기본값 = 거창 설계서 값 · 속도 · t 값은 CC000054~058 고르기로 바꿈. +- 로직 입력 채움(이름으로) — `Cm` · `사이클시간` = cm · `t2` = t2 · `운반거리` = L1 + L2 · 채운 칸 = 「거리에서」 표시 · 손으로 덮으면 그 값. +- 셈 자리 = 짝 ①(사이클시간 파이썬 · TS) · 서버가 B09 셈 때 거리이정표 거리로 다시 채워 정본(거리 바뀌면 단가도 따라감). +- 모달 하나 「운반 단가」 — 위 = 단가산출 이름 · [변경](단가연결 고르기) · 왼 = 옵션 값(로직 입력 + 사이클 입력) · 오른 = 계산 절차(사이클 식 줄 + 로직 산출 줄 · 새 길 `POST /api/projects/{id}/estimation/logic-preview` = `render_sangeun_text` 재사용) · 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]). +- 여는 곳 = 거리이정표 표 우클릭 [단가연결] · 설계내역서 운반공 줄 단가연결. 그 밖 줄 = 지금 단가연결 창. +- 원본 로직(master_data)은 고치지 않음 — 앱 코드가 셈해 입력값을 채움. + +## 2. 거리이정표 화면 (254 · sub8 / 255 · sub1 / 256 · sub4) + +지금 +- 기준점 = 단일 객체 `base`(+ 경로 `via_base`) — TS · PY · 시험 13곳이 고정 id `"base"` 를 씀. +- 다이어그램 — 항목 12px · 거래처 13px · 주소 한 줄 말줄임 · 상자 높이 고정(124×58 · 148×58) · 기준점 상자 = 이름만 · 연결 점 = 거래처 오른쪽 가운데 · 선 글 = 한 줄 「도로 km」 · 방위 배치 = 고정 반지름(R_BASE 390 · R_ROUTE 280 · 거리 무관). +- 상자 누름 모달 제목 = 「항목 정보 — 이름」(표 머리 「항목」). +- 시점 모달 = 중점 측점 m · 설명 줄 · 지도 = B01 타일 지도(`buildAddressMap`) · 시점만. +- 거래처 · 기준점 모달 = 제목 h3 · 「소재지 — 주소 찾기 · 지도에서 고르기(…)」 h4 · 주소 · 지도 사이 구분 없음 · 기준점 이름 칸 줄바꿈. +- 경로 탭 — 거래처 · 기준점만 끌기(시점 고정) · 기준점 옮기면 기준점→시점 구간만 다시 · 다시 열면 기준점→시점 선 없음 · 실패 안내 없음. +- 그림 — `auto/<경로 id>.png`(거래처 구간만 · 길찾기 때) · 화면이 안 씀 · 장소 그림 없음. + +정함(브레인) +- 여러 기준점 — `bases[]`(id · 이름 · 주소 · 좌표 · pos · legs · `to` = 시점 또는 다른 기준점 id) · 경로 `to`(시점 또는 기준점 id) · 옛 `base` · `via_base` 는 읽을 때 옮김(지우지 않음 · 저장 때 새 꼴). 돌기(A → B → A) 막음. +- 도로 구분 — 시점에 닿는 기준점 구간 = 1차포장 · 그 밖 기준점 구간(기준점 → 기준점 · 거래처 → 기준점) = 2차포장 · 거래처 → 시점 = 포장 · 비포장은 사용자 고름(지금과 같음). +- 선 길이 = 거리 비례(로그 · 최소 · 최대 둘레 안) — 너무 멀어지지 않게. +- 연결 점 = 상자 왼쪽 위 안쪽 · 기준점 상자에도. +- 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 없음). +- 시점 끌기 = 길찾기용 시점 자리 덮개(`site.start_pin` · 노선은 그대로). +- 장소 그림 = `milestone/place/<상자 id>.png`(작은 지도 · 표지) · 경로 그림 = `milestone/auto/<경로 id>.png`(경로 탭과 같은 전체 경로 · 기준점 사슬 포함) · 상자 · 경로 지우면 [저장] 때 지움 · 화면 GET 에 `place_images` 로 알림. + +## 3. B09 (257 · sub3) + +지금 +- 위 탭 = 총괄설계내역서 · 설계내역서 따로. +- 단가연결 칸 = 글(「연결됨 ▾」 등) · 머리 줄바꿈 안 됨(템플릿이 글로 넣고 nowrap · B09 css normal). +- 빨강 이동 상자 = 설계내역서 · 묶음 탭만 · 나머지 탭 이동 = 보통 강조(14% 바탕). +- 자재단가대비표 적용 단가 칸 = 22% 바탕 · 왼쪽 누름 메뉴만(우클릭 없음). +- 비고 「빈 줄 N」 = 제목 · 묶음 줄(Calc_Bill py · ts 짝). +- 총공사비 = 몸 줄(따라 스크롤) · 템플릿 `total` = 아래 고정 줄(tfoot sticky). + +정함(브레인) +- 위 탭 「설계내역」 = 아래 총괄설계내역서 · 설계내역서. +- 단가연결 칸 = 램프(초록 연결 · 노랑 없음 · B08 구조물집계표 꼴) · 누름 동작 그대로 · 풍선 = 무엇에 연결 · 머리 「단가↵연결」 · 폭 줄임. +- 이동해 온 줄 = 모든 탭에서 같은 빨강 상자. +- 적용 단가 = 초록 테두리(입력 칸 테두리와 다른 색) · 우클릭 「적용 단가 바꾸기」(출처 목록). +- 「빈 줄 N」 지움(「(제외금액)」 은 둠). +- 총괄설계내역서 · 공사원가계산서 총공사비 = 템플릿 아래 고정 줄(`total`) · 그 줄 이동 누름은 빠짐. + +## 4. 표 양식 · B05/B06 패널 (258 · sub5) + +지금 +- 거리이정표 표가 폭을 다 안 씀 — 템플릿이 표에 인라인 `width: Σpx` 를 박아 B08 css `width:100%` 를 이김(템플릿과 충돌). +- 입력 칸 테두리 = 243-1(`td.is-can-edit` outline)이 246-1(사용자 128)에서 빠짐 · 시험 셋이 빠진 꼴을 지킴. +- 구조물 조합 미지정 = 노랑(`--color-warning`) · 구조물리스트 램프 = 회색(`--color-text-muted`). + +정함(브레인) +- 템플릿 표 폭 = CSS 변수(`--dt-w`) — 페이지 css 가 덮을 수 있게 · 거리이정표 표 = 100%. +- 입력 칸 테두리 되살림 — 템플릿 전체 · 옅게(예전 60% → 25~30%) · outline(빨강 이동 상자는 box-shadow). +- 미지정 = 회색(구조물리스트 램프와 같은 색). + +## 5. B06 횡단 상세 (259 · sub7 / 260 · sub6) + +지금 +- 상세 모달 = `B06_Section_UI_Cross_Detail*.ts` · 선은 교차점에서 나눔(`splitAtCrossings`) · 암 경계선은 안 들어감(그리기만). +- 고름 — 선 여럿 고르면 길이 합 · 닫힘이면 면적 · 영역 안 누름 고르기 없음 · 채움 강조 없음 · 표 칸 누름 = 길이 · 면적 안 보임 · 저장된 선을 그래프에서 다시 안 고름. +- ⚠ 공용 `A00_Common/b_svg_zoom_pan.ts` — 가운데 버튼 팬 뒤 `moved` 가 안 풀려 왼쪽 누름을 모두 삼킴 → 팬 한 번 뒤 고르기 · 길이 표시가 멈춤(「길이 정보 안 나옴」의 까닭). +- 표 강조 = 칸마다 빨강 inset 테두리 → 붙은 칸에 빨강 · 회색 · 빨강 겹선 · `tr:has(.is-picked)` 가 다른 묶음까지 물듦. +- 그래프 아래 = 글 상자(`structure_note` · 2000자) → B07 이 표 위에 글 줄로 놓음(`_cross_note_entities`). +- CAD 는 iframe(`ui_template/cad_host`) · 모달 안 빈 도면 이미 씀(M02 · B07 도면 관리). +- 횡단배수 자료 = `pipe_points.json`(관경 · 유입 · 유출 형식 · 찰/메 · 높이 · 길이 · 도수로/돌붙임 추가 = 하나 · 쪽 없음) · 관 길이 L = `design.pipe_length_m`(주인 측점). + +정함(브레인) +- 암 경계선도 나눔 · 고름(서버 `pick_lines` · 스키마 포함 다섯 곳). +- 닫힌 영역 = 영역 안 누름으로 고름(열린 영역 제외) · 채움 강조 · 면적 · 둘레 보임 · 선 = 길이 · 기울기 · 수평 · 높이 · 표 칸 누름 = 그 칸 선 · 영역을 그래프에서 고르고 같은 정보. +- 공용 팬 `moved` 고침(왼쪽 누름 때 풂) — 거리이정표 다이어그램도 같은 줄을 씀. +- 표 강조 = 고른 칸 묶음 바깥 한 줄 · 겹선 없음 · 다른 묶음 안 물듦. +- 글 상자 → 빈 CAD 도면(바탕만) · 새 칸 `structure_note_cad`(엔티티) · 옛 글이 있으면 처음 열 때 글로 옮김 · B07 = CAD 칸이 있으면 표 위에 그 엔티티(좌상 맞춤 · 크기 = 범위) · 없으면 옛 글 줄. +- 미리 넣음 = 횡단배수 주인 측점만 · 비었을 때만(사용자 그림 덮지 않음) · 「NO.측점」 · 원 안 「배수관 / Φ관경m/m / L=길이m」 · 관보호공(유입 · 유출) 형식(찰|메, H=높이) L=길이m · 도수로/돌붙임 추가 = 「관유출부보강(돌붙임,찰) 면적m2」(쪽 칸이 없어 유출부로 · 사용자가 고침). From 24be69f325dbf78537b3dde9a8a33aebbc0e8ba3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:58:00 +0900 Subject: [PATCH 032/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=8B=9C=EC=A0=90=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=E2=80=94=20=EC=A4=91=EC=A0=90=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20km=20=C2=B7=20=EC=84=A4=EB=AA=85=20=EC=A4=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=8B=9C=EC=A0=90=20=C2=B7=20=EC=A4=91=EC=A0=90=20?= =?UTF-8?q?=ED=91=9C=EC=A7=80=20(255-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../B08_Quantity_UI_Milestone_Modal.ts | 77 +++++++++---------- .../tester/test_244_4_b08_milestone_modal.py | 6 +- .../test_244_6_b08_milestone_pan_client.py | 4 +- .../test_255_1_b08_milestone_site_modal.py | 51 ++++++++++++ 4 files changed, 93 insertions(+), 45 deletions(-) create mode 100644 resources/tester/test_255_1_b08_milestone_site_modal.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 1942871d0..49ee0f910 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -10,7 +10,7 @@ * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 - * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지) * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ @@ -24,7 +24,6 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; import { roadOf, type Dest, @@ -32,7 +31,9 @@ import { type MilestoneRow, type Road, } from "./B08_Quantity_Milestone_Calc"; +import { setLeg } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { findSegmentKm, @@ -231,27 +232,26 @@ export interface SiteAddress { mid_address: string | null; } -/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ title: "공사지 — 시점 · 중점", closeLabel: "닫기", + dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const text = (label: string, value: string): HTMLElement => - el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); - // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1) const data = liveMilestone(projectId); const site = siteInfo(); - const km = (m: number | null | undefined): string => - m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); const mid = createInputField({ - label: "중점 측점 (m)", + label: "중점 측점 (km)", type: "number", min: 0, compact: true, }); - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; - mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + mid.input.step = "0.001"; + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m / 1000) : ""; + mid.input.value = + data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", @@ -263,40 +263,39 @@ export function openSiteInfo(projectId: string): void { label: "수정", variant: "filled", onClick: () => { + const text = mid.input.value; + const value = Number(text); + const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null; const fresh = liveMilestone(projectId); - const value = Number(mid.input.value); - fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; - fresh.site.mid_road = road.select.value as Road; - putMilestone(projectId, fresh); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { road: road.select.value as Road, km }), + ); close(); }, }); save.dataset.b08MilestoneSite = "save"; actions.append(save); - const map = buildAddressMap(); - const info = el("div", { children: [text("주소", "불러오는 중…")] }); - body.append( - line(mid.root, road.root), - text( - "시점 = 노선 첫 점(측점 0)", - `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, - ), - info, - map.root, - ); - request(`/projects/${projectId}/quantity/milestone/site-address`).then( - (s) => { - const ll = (p: [number, number] | null): string => - p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; - info.replaceChildren( - text("프로젝트 등록 주소(사업지역)", s.region || "없음"), - text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), - text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), - ); - if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); - else if (s.region) void map.show(s.region); - }, - (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), + const map = editMap(projectId, { hint: "", onMove: () => undefined }); + const pins = (s: { + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + }): MapPin[] => + ( + [ + ["start", "시점", s.start_lonlat], + ["mid", "중점", s.mid_lonlat], + ] as const + ).flatMap(([key, name, p]) => + p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [], + ); + if (site) map.set(pins(site), []); + body.append(line(mid.root, road.root), map.root); + // 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시 + const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => map.set(pins(s), []), + (error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"), ); }, }); diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 613e5d68d..d64ab1ad7 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -147,10 +147,8 @@ def test_화면_모달_팬줌_그림탭(): place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place assert "searchAddress(text)" in place - assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도 - # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 - assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal - assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal + # ② 시점 모달 — 지도 = 공용 카카오 지도(255-1 · 주소 줄 지움) + assert "/quantity/milestone/site-address" in modal and "editMap(projectId" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): assert text in add diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 9e609a8ef..fc391dd70 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -116,8 +116,8 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") site = modal[modal.index("export function openSiteInfo") :] - assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site - assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site + assert 'label: "중점 측점 (km)"' in site and 'label: "시점 → 중점 도로"' in site + assert 'setLeg(fresh, "start", 0, {' in site and "putMilestone(" in site # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") diff --git a/resources/tester/test_255_1_b08_milestone_site_modal.py b/resources/tester/test_255_1_b08_milestone_site_modal.py new file mode 100644 index 000000000..e944a6d05 --- /dev/null +++ b/resources/tester/test_255_1_b08_milestone_site_modal.py @@ -0,0 +1,51 @@ +"""255-1 거리이정표 시점 모달 — 중점 측점 km 로 보이고 받음(저장은 m) · 설명 줄 지움 · +지도 = 공용 카카오 지도(시점 · 중점 표지).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _site(km): + data = {"site": {"mid_station_m": 999, "mid_road": "비포장"}, "base": {}, "routes": []} + return _node(f'setLeg({json.dumps(data)}, "start", 0, {{ km: {json.dumps(km)} }}).site') + + +def test_중점_km_저장_m(): + assert _site(1.2345)["mid_station_m"] == 1234.5 + assert _site(0.0005)["mid_station_m"] == 0.5 + assert _site(None)["mid_station_m"] is None # 빈칸 = 노선 ÷ 2 + + +def test_시점_모달_km_카카오_지도(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert 'label: "중점 측점 (km)"' in site + assert "String(data.site.mid_station_m / 1000)" in site # 보임 = km + assert 'setLeg(fresh, "start", 0, {' in site # 받음 km → 저장 m + # 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음 + for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"): + assert gone not in modal + assert "editMap(projectId" in site + assert '["start", "시점", s.start_lonlat]' in site and '["mid", "중점", s.mid_lonlat]' in site + assert 'dialogClass: "b08-milestone__modal"' in site # 다른 모달과 같은 창 · 지도 높이 From 7fdbcc4794321f3e6c835d4335848e4b9ae2a350 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:58:00 +0900 Subject: [PATCH 033/151] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=97=AC=EB=9F=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=A0=90=20=E2=80=94=20bases[]=20=C2=B7=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20to=20=C2=B7=20=EC=98=9B=20base=20=EC=98=AE=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20=EC=82=AC=EC=8A=AC=20=EC=85=88(254-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to - 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴) - 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) - 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차 - 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬 - 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Haul.py | 2 +- B08_Quantity/B08_Quantity_Milestone_Calc.py | 71 +++++- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 142 +++++++++--- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 186 +++++++++------ B08_Quantity/B08_Quantity_Router_Milestone.py | 27 ++- B08_Quantity/B08_Quantity_UI_Milestone.ts | 6 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 25 +- .../B08_Quantity_UI_Milestone_LegModal.ts | 36 ++- .../B08_Quantity_UI_Milestone_Modal.ts | 58 +++-- .../B08_Quantity_UI_Milestone_Route.ts | 13 +- .../B08_Quantity_UI_Milestone_State.ts | 29 ++- .../test_244_12_b08_milestone_base_view.py | 2 + .../test_244_13_b08_milestone_place_leg.py | 45 ++-- .../test_244_2_b08_milestone_diagram.py | 11 +- .../tester/test_244_4_b08_milestone_modal.py | 17 +- .../test_244_5_b08_milestone_base_dir.py | 14 +- .../test_244_6_b08_milestone_pan_client.py | 12 +- .../tester/test_244_7_b08_milestone_kakao.py | 22 +- .../test_244_9_b08_milestone_route_map.py | 5 +- .../test_250_1_b08_milestone_roads_width.py | 21 +- .../tester/test_254_1_b08_milestone_bases.py | 213 ++++++++++++++++++ 21 files changed, 694 insertions(+), 263 deletions(-) create mode 100644 resources/tester/test_254_1_b08_milestone_bases.py diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index 93a01af50..c3e509662 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -118,7 +118,7 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict "id": f"h-{g['key']}"[:40], "name": "", "address": "", - "via_base": True, + "to": "site", "linked": False, "dir": None, "haul": g["key"], diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 006196a3d..2f2195984 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -4,14 +4,16 @@ 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. -표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · +`base_chain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7). -도로 구분(152) = 포장 종류는 선 자리로 정함(`road_of`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = -1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · -포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`road_of`) — 시점에 닿는 기준점 구간 = 1차포장 · +그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 +구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrate_milestone` 로 새 꼴(`bases[]` · `to`). """ from __future__ import annotations @@ -21,8 +23,10 @@ from typing import Any ROADS = ("2차포장", "1차포장", "포장", "비포장") KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} -# 구간 자리 → 포장 종류(152) — via = 거래처 → 기준점 · base = 기준점 → 시점 · direct = 거래처 → 시점 · mid = 시점 → 중점 +# 구간 자리 → 포장 종류(152 · 254) — via = → 기준점 · base = 기준점 → 시점 · +# direct = 거래처 → 시점 · mid = 시점 → 중점 PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"} +SITE = "site" # 이음 대상 = 시점(그 밖 = 기준점 id) def road_of(road: str | None, kind: str) -> str: @@ -55,21 +59,64 @@ def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: return float(station or 0) / 1000 +def base_chain(data: dict[str, Any], to: str) -> list[dict[str, Any]]: + """기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · + 안 이은 · 없는 기준점에서 멈춤.""" + by_id = {b.get("id"): b for b in data.get("bases") or []} + out: list[dict[str, Any]] = [] + seen: set[str] = set() + while to != SITE and to not in seen: + seen.add(to) + base = by_id.get(to) + if base is None or base.get("linked", True) is False: + break + out.append(base) + to = base.get("to") or SITE + return out + + +def migrate_milestone(data: dict[str, Any]) -> dict[str, Any]: + """옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · + 경로 `via_base` = to "b1" | "site" · 새 꼴은 그대로(사본).""" + rest = {k: v for k, v in data.items() if k != "base"} + old = data.get("base") or {} + fresh = isinstance(rest.get("bases"), list) + on = not fresh and bool(old.get("enabled")) + if fresh: + bases = rest["bases"] + elif on: + keep = ("name", "address", "dir", "lon", "lat", "pos", "manual", "legs") + bases = [{**{k: old[k] for k in keep if k in old}, "id": "b1", "to": SITE, "linked": True}] + else: + bases = [] + routes = [] + for route in rest.get("routes") or []: + if not isinstance(route.get("to"), str): + via = on and route.get("via_base", True) is not False + route = {k: v for k, v in route.items() if k != "via_base"} + route["to"] = "b1" if via else SITE + routes.append(route) + return {**rest, "bases": bases, "routes": routes} + + +def _kind(owner: dict[str, Any], to_site: str, to_base: str) -> str: + return to_site if (owner.get("to") or SITE) == SITE else to_base + + def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + data = migrate_milestone(data) site = data.get("site") or {} - base = data.get("base") or {} mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)} out: list[dict[str, Any]] = [] for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} - linked = route.get("linked", True) is not False # 안 이은 거래처 = 구간 km 없음(155) - via = linked and bool(base.get("enabled")) and route.get("via_base", True) is not False - if linked: - add_legs(sums, route.get("legs"), "via" if via else "direct") - if via: - add_legs(sums, base.get("legs"), "base") + # 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + if route.get("linked", True) is not False: + add_legs(sums, route.get("legs"), _kind(route, "direct", "via")) + for base in base_chain(data, route.get("to") or SITE): + add_legs(sums, base.get("legs"), _kind(base, "base", "via")) if row.get("dest") == "mid": add_legs(sums, [mid], "mid") paved = sums["s2"] + sums["s1"] + sums["pv"] diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 5c1bac853..a26bbab10 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -6,20 +6,23 @@ * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. * - * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · + * `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) - * 도로 구분(152) = 포장 종류는 선 자리로 정함(`roadOf`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = - * 1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · - * 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`roadOf`) — 시점에 닿는 기준점 구간 = 1차포장 · + * 그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 + * 구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrateMilestone` 로 새 꼴(`bases[]` · `to`) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; -/** 구간 자리(152) — 거래처 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · 시점 → 중점(mid) */ +/** 구간 자리(152 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · + * 시점 → 중점(mid) */ export type SegKind = "via" | "base" | "direct" | "mid"; -/** 포장 종류 = 선 자리로 정함(152) — 기준점이 생기면 거래처 → 기준점 = 2차 · 기준점 → 시점 = 1차 · 없으면 포장 */ +/** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */ export const PAVED: Record = { via: "2차포장", base: "1차포장", @@ -36,20 +39,81 @@ export function withRoads(data: MilestoneData): MilestoneData { return { ...data, site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, - base: { ...data.base, legs: fix(data.base.legs, "base") }, - routes: data.routes.map((r) => ({ - ...r, - legs: fix(r.legs, data.base.enabled && r.via_base !== false ? "via" : "direct"), - })), + bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })), + routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })), }; } -/** 선 자리 — from = 거래처 id · "base" · "start"(시점 → 중점) */ +/** 이음 대상 = 시점(254 · 그 밖 = 기준점 id) */ +export const SITE_ID = "site"; +const baseKind = (b: { to: string }): SegKind => (b.to === SITE_ID ? "base" : "via"); +const routeKind = (r: { to: string }): SegKind => (r.to === SITE_ID ? "direct" : "via"); + +/** 선 자리 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function segKind(data: MilestoneData, from: string): SegKind { if (from === "start") return "mid"; - if (from === "base") return "base"; + const base = data.bases.find((b) => b.id === from); + if (base) return baseKind(base); const route = data.routes.find((r) => r.id === from); - return data.base.enabled && route?.via_base !== false ? "via" : "direct"; + return route ? routeKind(route) : "direct"; +} + +/** 기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · 안 이은 · 없는 기준점에서 멈춤 */ +export function baseChain(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + let id = to; + while (id !== SITE_ID && !seen.has(id)) { + seen.add(id); + const base = data.bases.find((b) => b.id === id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +type OldBase = Partial & { enabled?: boolean }; +type OldData = Partial> & { + base?: OldBase | null; + routes?: Array & { via_base?: boolean }>; +}; + +/** 옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · 경로 `via_base` = to "b1" | "site" · + * 새 꼴은 그대로(사본) */ +export function migrateMilestone(data: OldData | MilestoneData): MilestoneData { + const { base: old, ...rest } = data as OldData; + const fresh = Array.isArray(rest.bases); + const on = !fresh && !!old?.enabled; + const bases: MilestoneBase[] = fresh + ? rest.bases! + : on + ? [ + { + id: "b1", + name: old!.name ?? "", + address: old!.address ?? "", + dir: old!.dir ?? null, + lon: old!.lon ?? null, + lat: old!.lat ?? null, + pos: old!.pos ?? null, + manual: !!old!.manual, + legs: old!.legs ?? [], + to: SITE_ID, + linked: true, + }, + ] + : []; + const routes = (rest.routes ?? []).map((r) => { + if (typeof r.to === "string") return r as MilestoneRoute; + const { via_base, ...keep } = r; + return { ...keep, to: on && via_base !== false ? "b1" : SITE_ID } as MilestoneRoute; + }); + return { + site: rest.site ?? { mid_station_m: null, mid_road: "비포장" }, + bases, + routes, + }; } /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; @@ -76,8 +140,8 @@ export interface MilestoneRoute { /** 업체 이름 = 하위 탭 이름 */ name: string; address: string; - /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ - via_base: boolean; + /** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */ + to: string; dir: Dir | null; lon?: number | null; lat?: number | null; @@ -91,25 +155,32 @@ export interface MilestoneRoute { haul?: string | null; /** 단가연결(251 · dg · sg · logic 꼴 · 정본) */ ref?: Record | null; - /** 업체 → 기준점(거침) · 시점 구간 */ + /** 업체 → to(기준점 · 시점) 구간 */ legs: Leg[]; rows: MilestoneRow[]; } +/** 기준점(254 · 여럿) — 거래처가 거쳐 가는 자리 · to = 시점 또는 다른 기준점 */ +export interface MilestoneBase { + id: string; + name: string; + address: string; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + pos?: [number, number] | null; + manual?: boolean; + /** 이 기준점 → to 구간 */ + legs: Leg[]; + /** 잇는 곳 — "site"(시점) · 다른 기준점 id */ + to: string; + /** false = 아직 선 안 이음(요소 추가 뒤) · 없음 = 이음 */ + linked?: boolean; +} + export interface MilestoneData { site: { mid_station_m: number | null; mid_road: Road }; - base: { - enabled: boolean; - name: string; - address: string; - dir: Dir | null; - lon?: number | null; - lat?: number | null; - pos?: [number, number] | null; - manual?: boolean; - /** 기준점 → 시점 공통 구간 */ - legs: Leg[]; - }; + bases: MilestoneBase[]; routes: MilestoneRoute[]; } @@ -153,19 +224,20 @@ export function midKm(data: MilestoneData, midDefaultM: number | null): number { /** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ export function computeMilestone( - data: MilestoneData, + raw: MilestoneData, midDefaultM: number | null, ): MilestoneTableRow[] { - const base = data.base; + const data = migrateMilestone(raw); const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) }; const out: MilestoneTableRow[] = []; for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; - const linked = route.linked !== false; // 안 이은 거래처 = 구간 km 없음(155) - const via = linked && !!base?.enabled && route.via_base !== false; - if (linked) addLegs(sums, route.legs, via ? "via" : "direct"); - if (via) addLegs(sums, base.legs, "base"); + if (route.linked !== false) { + // 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + addLegs(sums, route.legs, routeKind(route)); + for (const b of baseChain(data, route.to)) addLegs(sums, b.legs, baseKind(b)); + } if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; out.push({ diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index d3f7016da..f2de77c52 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -18,7 +18,7 @@ * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 * ========================================================================== */ -import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc"; export interface DiagramBox { id: string; @@ -201,7 +201,7 @@ export function setAutoKm(legs: Leg[], km: number): Leg[] { } export interface KmSegment { - /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + /** route = 거래처 구간 · base = 기준점 구간(→ 시점 · 다른 기준점) */ key: "route" | "base"; text: string; from: [number, number] | null; @@ -211,40 +211,64 @@ export interface KmSegment { const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => p.lon != null && p.lat != null ? [p.lon, p.lat] : null; -/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) · - * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +/** 이음 대상 = 시점(Calc `SITE_ID` 와 같음 · 형 import 만) */ +const TO_SITE = "site"; +const baseOf = (data: MilestoneData, id: string): MilestoneBase | undefined => + data.bases.find((b) => b.id === id); +/** 구간 주인 — 기준점 · 거래처(from id) */ +export const ownerOf = (data: MilestoneData, from: string) => + baseOf(data, from) ?? data.routes.find((r) => r.id === from); + +/** 기준점 사슬(254 · Calc `baseChain` 과 같음) — to 부터 시점까지 · 돌기 · 안 이음 · 없음에서 멈춤 */ +export function chainOf(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id);) { + seen.add(id); + const base = baseOf(data, id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */ +export function makesLoop(data: MilestoneData, from: string, to: string): boolean { + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id); id = baseOf(data, id)?.to ?? TO_SITE) { + if (id === from) return true; + seen.add(id); + } + return false; +} + +/** 구간 한 줄(244-13 연결선) — from = 거래처 id · 기준점 id(→ to = 시점 · 기준점) · + * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함 · 안 이음) */ export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null { - const baseName = data.base.name || "기준점"; - if (from === "base") - return data.base.enabled - ? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null } - : null; - const route = data.routes.find((r) => r.id === from); - if (!route || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음 - const viaBase = data.base.enabled && route.via_base; + const base = baseOf(data, from); + const owner = base ?? data.routes.find((r) => r.id === from); + if (!owner || owner.linked === false) return null; // 155 안 이은 상자 = 구간 없음 + const target = owner.to === TO_SITE ? undefined : baseOf(data, owner.to); + if (owner.to !== TO_SITE && !target) return null; return { - key: "route", - text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, - from: pointOf(route), - to: viaBase ? pointOf(data.base) : (start ?? null), + key: base ? "base" : "route", + text: `${owner.name || (base ? "기준점" : "거래처")} → ${target ? target.name || "기준점" : "시점"}`, + from: pointOf(owner), + to: target ? pointOf(target) : (start ?? null), }; } -/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */ -export function chainVia(data: MilestoneData, from: string): boolean { - if (!data.base.enabled || from === "start") return false; - return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base; -} - -/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 +/** [길찾기] 구간 — 거래처 → to · 그 뒤 기준점 사슬 구간들(254) * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { const first = segmentEnds(data, routeId, start); - if (!first) return []; - return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first]; + const route = data.routes.find((r) => r.id === routeId); + if (!first || !route) return []; + return [first, ...chainOf(data, route.to).map((b) => segmentEnds(data, b.id, start)!)]; } -/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +/** 카카오 지도 표지(244-9) — 거래처 · 기준점 사슬(좌표 있을 때) · 시점 */ export function mapMarks( data: MilestoneData, routeId: string, @@ -257,19 +281,15 @@ export function mapMarks( if (p) out.push({ name, lon: p[0], lat: p[1] }); }; add(route.name || "거래처", pointOf(route)); - if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base)); + if (route.linked !== false) + for (const b of chainOf(data, route.to)) add(b.name || "기준점", pointOf(b)); add("시점", start ?? null); return out; } -/** 자동 km 를 그 구간에(사용자 km 는 그대로) */ -export function putAutoKm( - data: MilestoneData, - routeId: string, - key: KmSegment["key"], - km: number, -): MilestoneData { - const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs; +/** 자동 km 를 그 구간에(from = 거래처 · 기준점 id · 사용자 km 는 그대로) */ +export function putAutoKm(data: MilestoneData, from: string, km: number): MilestoneData { + const legs = ownerOf(data, from)?.legs; if (legs) setAutoKm(legs, km); return data; } @@ -363,8 +383,7 @@ export function layoutMilestone( }; /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ // 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리 - const posOf = (id: string): [number, number] | null | undefined => - id === "base" ? data.base.pos : data.routes.find((r) => r.id === id)?.pos; + const posOf = (id: string): [number, number] | null | undefined => ownerOf(data, id)?.pos; const put = (box: DiagramBox, out?: [number, number]): void => { const inner = box.w - 2 * INSET; box.label = clip(box.label, inner, FONT.label); @@ -411,31 +430,60 @@ export function layoutMilestone( line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; - const useBase = data.base.enabled; - const baseLL: LonLat = - data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null; - let baseDeg = 270; - if (useBase) { - baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270; - const [ux, uy] = unit(baseDeg); - const cx = ux * R_BASE; - const cy = uy * R_BASE; + const llOf = (p?: { lon?: number | null; lat?: number | null }): LonLat => + p && p.lon != null && p.lat != null ? [p.lon, p.lat] : null; + const used = new Set(); + const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; + /** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */ + const degOf = new Map(); + let auto = 0; + /** 방위 안 정한 상자 — 빈 8방 차례(기준점 둘레 = 잇는 곳 반대편부터 · 잇는 곳으로 향하는 쪽 뺌) */ + const free = (anchor: string): number => { + const turn = anchor === "site" ? 0 : (degOf.get(anchor) ?? 0); + const blocked = anchor === "site" ? -1 : turn + 180; + const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( + (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), + ); + return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; + }; + /** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */ + const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site"); + + // 기준점(254 · 여럿) — 잇는 곳(시점 · 다른 기준점) 기준 방위(좌표 → 방위각 · 없으면 서부터 빈 방위) · + // 잇는 곳이 먼저 놓이게 사슬 차례 · 돌기 = 공사지 둘레 + const waiting = [...data.bases]; + while (waiting.length) { + let i = waiting.findIndex( + (b) => b.linked === false || b.to === "site" || byId.has(b.to) || !ownerOf(data, b.to), + ); + if (i < 0) i = 0; + const [b] = waiting.splice(i, 1); + const anchor = b.linked === false ? "site" : anchorOf(b.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + const deg = + placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ?? + (anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor)); + used.add(keyOf(anchor, deg)); + degOf.set(b.id, deg); + const [ax, ay] = center(anchor); + const [ux, uy] = unit(deg); put( { - id: "base", + id: b.id, kind: "base", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + x: ax + ux * R_BASE - BOX.w / 2, + y: ay + uy * R_BASE - BOX.h / 2, w: BOX.w, h: BOX.h, - label: data.base.name || "기준점", + label: b.name || "기준점", }, [ux, uy], ); - line("base", "start", data.base.legs); + if (b.linked !== false && (b.to === "site" || byId.has(b.to))) + line(b.id, b.to === "site" ? "start" : b.to, b.legs); } - // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + // 거래처 — 잇는 곳(기준점 · 공사지) 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, Array<{ @@ -446,25 +494,15 @@ export function layoutMilestone( anchor: string; deg: number; legs: Leg[]; + linked: boolean; }> >(); - const used = new Set(); - const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; const items = data.routes.map((r) => { - const anchor = useBase && r.via_base ? "base" : "site"; - return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) }; + const anchor = anchorOf(r.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + return { r, anchor, deg: placeDeg(r, anchorLL) }; }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); - let auto = 0; - /** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ - const free = (anchor: string): number => { - const turn = anchor === "base" ? baseDeg : 0; - const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; - const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( - (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), - ); - return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; - }; for (const { r, anchor, deg } of items) { const d = deg ?? free(anchor); used.add(keyOf(anchor, d)); @@ -479,11 +517,13 @@ export function layoutMilestone( anchor, deg: d, legs: r.legs, + // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 + linked: r.linked !== false && (r.to === "site" || anchor === r.to), }); } for (const group of groups.values()) { group.forEach((g, k) => { - const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); + const [ax, ay] = center(g.anchor); const [ux, uy] = unit(g.deg); // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; @@ -506,9 +546,7 @@ export function layoutMilestone( }, [ux, uy], ); - // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) - if (data.routes.find((r) => r.id === g.id)?.linked !== false) - line(g.id, g.anchor === "base" ? "base" : "start", g.legs); + if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs); }); } @@ -530,11 +568,10 @@ export function layoutMilestone( }; } -/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +/** 선 글 한 줄 → 그 구간 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - return legs?.[i] ?? null; + return ownerOf(data, from)?.legs[i] ?? null; } /** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ @@ -550,8 +587,7 @@ export function setLeg( data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000; return data; } - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - const leg = legs?.[i]; + const leg = ownerOf(data, from)?.legs[i]; if (leg) Object.assign(leg, patch); return data; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index e68cccdbc..5ecf8d0de 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -27,6 +27,8 @@ 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +기준점 = `bases[]`(254 · 여럿 · to = 시점 또는 다른 기준점) · 경로 `to` — 옛 `base` · `via_base` 는 +읽을 때 · 받을 때 새 꼴로 옮김(`migrate_milestone` · 저장 = 새 꼴). """ from __future__ import annotations @@ -50,7 +52,7 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3 from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets @@ -71,6 +73,7 @@ DATA_FILE = "milestone.json" AUTO = "auto" # 자동 그림 하위 폴더(244-11) IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" +BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀 Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -102,7 +105,7 @@ class Place(BaseModel): class Route(Place): id: str = Field(pattern=ROUTE_ID) - via_base: bool = True + to: str = Field(default=SITE, pattern=ROUTE_ID) # 254 잇는 곳 = "site"(시점) · 기준점 id # 251 운반 묶음 키(총괄자재 · B09 운반 줄과 이음) · 단가연결(dg · sg · logic 꼴 · 정본) haul: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z0-9_-]+$") ref: dict[str, Any] | None = None @@ -111,7 +114,11 @@ class Route(Place): class Base(Place): - enabled: bool = False + """기준점(254 · 여럿) — to = 시점 또는 다른 기준점 id(돌기는 화면이 막고 셈이 끊음).""" + + id: str = Field(pattern=ROUTE_ID) + to: str = Field(default=SITE, pattern=ROUTE_ID) + linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤) class Site(BaseModel): @@ -121,14 +128,22 @@ class Site(BaseModel): class Milestone(BaseModel): site: Site = Field(default_factory=Site) - base: Base = Field(default_factory=Base) + bases: list[Base] = Field(default_factory=list, max_length=20) routes: list[Route] = Field(default_factory=list, max_length=100) + @model_validator(mode="before") + @classmethod + def _migrate(cls, data: Any) -> Any: + """옛 꼴(단일 base · via_base) → 새 꼴(254).""" + return migrate_milestone(data) if isinstance(data, dict) else data + @model_validator(mode="after") def _unique_ids(self) -> Milestone: - ids = [route.id for route in self.routes] + ids = [b.id for b in self.bases] + [route.id for route in self.routes] if len(ids) != len(set(ids)): - raise ValueError("경로 id 가 겹칩니다.") + raise ValueError("경로 · 기준점 id 가 겹칩니다.") + if BOX_IDS & set(ids): + raise ValueError("site · start · mid 는 id 로 못 씁니다.") return self diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 148f27c34..3de113da2 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -87,7 +87,7 @@ export function addRoute(projectId: string, init: Partial = {}): id, name: `거래처 ${data.routes.length + 1}`, address: "", - via_base: true, + to: "site", dir: null, legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) rows: [{ item: "", dest: "start", note: "" }], @@ -168,12 +168,12 @@ function tableView(projectId: string): MilestoneView { const data = liveMilestone(projectId); const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; const fresh = makeTable( milestoneColumns( - data.base.enabled, + data.bases.length > 0, rows.some((r) => r.pv > 0), ), ); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e7595d8a1..ee2fe6286 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -28,6 +28,7 @@ import { import { layoutMilestone, legAt, + ownerOf, panContent, panStart, setLeg, @@ -158,7 +159,7 @@ export function createDiagram( site: () => openSiteInfo(projectId), start: () => openSiteInfo(projectId), mid: () => openSiteInfo(projectId), - base: () => openPlaceModal(projectId, { add: false, id: "base" }), + base: (id) => openPlaceModal(projectId, { add: false, id }), }; /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) @@ -189,7 +190,7 @@ export function createDiagram( Math.round(box.y + (e.clientY - down.clientY) / k), ]; const fresh = liveMilestone(projectId); - const target = box.id === "base" ? fresh.base : fresh.routes.find((r) => r.id === box.id); + const target = ownerOf(fresh, box.id); if (!target) return; target.pos = pos; userZoomed = true; // 옮긴 뒤 배율 그대로 @@ -200,8 +201,9 @@ export function createDiagram( }); }; - /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · - * 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + 길찾기 한 번(km · 경로 그림) */ + /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) · + * 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + + * 길찾기 한 번(km · 경로 그림) */ const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { const cx = box.x + box.w; const cy = box.y + box.h / 2; @@ -221,12 +223,13 @@ export function createDiagram( const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy }); shift.append(wire); let over: Element | null = null; - const targetOf = (e: PointerEvent): "base" | "site" | null => { + /** 놓을 곳 — 기준점 id · "site"(공사지 · 시점 · 중점) · 없음 */ + const targetOf = (e: PointerEvent): string | null => { const hit = document.elementFromPoint(e.clientX, e.clientY); const g = hit?.closest("[data-b08-milestone-box]"); const id = g?.dataset.b08MilestoneBox; - if (id === "base") return "base"; - return id === "site" || id === "start" || id === "mid" ? "site" : null; + if (id === "site" || id === "start" || id === "mid") return "site"; + return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null; }; const move = (e: PointerEvent): void => { const [x, y] = toLocal(e); @@ -249,10 +252,8 @@ export function createDiagram( const fresh = liveMilestone(projectId); const route = fresh.routes.find((r) => r.id === box.id); if (!t || !route) return; - const via = t === "base"; - const now = route.linked === false ? null : fresh.base.enabled && route.via_base !== false; - if (via === now) return; // 같은 연결 = 그대로 - route.via_base = via; + if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로 + route.to = t; route.linked = true; // 155 요소 추가 뒤 첫 이음 route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 putMilestone(projectId, fresh); @@ -270,7 +271,7 @@ export function createDiagram( variant: "ghost", onClick: () => { const fresh = liveMilestone(projectId); - fresh.base.pos = null; + fresh.bases.forEach((b) => (b.pos = null)); fresh.routes.forEach((r) => (r.pos = null)); userZoomed = false; putMilestone(projectId, fresh); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 988a48bf3..0f556f114 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -3,8 +3,8 @@ * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. * * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) - * 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · - * 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · + * 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) @@ -22,7 +22,7 @@ import { type Road, type SegKind, } from "./B08_Quantity_Milestone_Calc"; -import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; +import { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; @@ -144,7 +144,7 @@ function openMidLeg(projectId: string): void { }); } -/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */ +/** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */ export function openLegInfo(projectId: string, from: string): void { if (from === "start") return openMidLeg(projectId); const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; @@ -155,11 +155,8 @@ export function openLegInfo(projectId: string, from: string): void { closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const legsOf = (): Leg[] => { - const d = liveMilestone(projectId); - return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; - }; - const via = chainVia(liveMilestone(projectId), from); + const legsOf = (): Leg[] => ownerOf(liveMilestone(projectId), from)?.legs ?? []; + const isBase = liveMilestone(projectId).bases.some((b) => b.id === from); const kind = segKind(liveMilestone(projectId), from); let legs = legsEditor(legsOf(), kind); const status = hint(""); @@ -167,7 +164,7 @@ export function openLegInfo(projectId: string, from: string): void { /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ const apply = (): void => { const fresh = liveMilestone(projectId); - const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from); + const target = ownerOf(fresh, from); if (!target) return; target.legs = legs.value(); putMilestone(projectId, fresh); @@ -177,18 +174,20 @@ export function openLegInfo(projectId: string, from: string): void { legs.root.replaceWith(next.root); legs = next; }; + /** 표지 = 그 구간 두 끝(주인 · to = 기준점 또는 시점) */ const pins = (): MapPin[] => { const d = liveMilestone(projectId); - const route = d.routes.find((r) => r.id === from); + const owner = ownerOf(d, from); const out: MapPin[] = []; const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => { if (lon != null && lat != null) out.push({ key, name, lon, lat, movable: key !== "start" }); }; - if (route) add(route.id, route.name || "거래처", route.lon, route.lat); - if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + if (!owner) return out; + add(owner.id, owner.name || (isBase ? "기준점" : "거래처"), owner.lon, owner.lat); + const target = d.bases.find((b) => b.id === owner.to); + if (target) add(target.id, target.name || "기준점", target.lon, target.lat); else add("start", "시점", start()?.[0], start()?.[1]); - if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); return out; }; const drawMap = (): void => { @@ -214,7 +213,7 @@ export function openLegInfo(projectId: string, from: string): void { apply(); void reverseAddress(projectId, lon, lat).then((address) => { const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key); + const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address }); putMilestone(projectId, fresh); @@ -242,14 +241,13 @@ export function openLegInfo(projectId: string, from: string): void { save.dataset.b08MilestoneLegSave = ""; actions.append(find, save); // 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기 - const owner = (d = liveMilestone(projectId)) => - from === "base" ? d.base : d.routes.find((r) => r.id === from); + const owner = (d = liveMilestone(projectId)) => ownerOf(d, from); const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)"); manual.input.checked = !!owner()?.manual; manual.input.dataset.b08MilestoneManual = ""; const auto = hint( - from === "base" - ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + isBase + ? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", ); const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 1942871d0..726387ccb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -8,7 +8,8 @@ * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 * 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음) * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) - * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 + * 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) · + * 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 조작 = 캐시 · [저장] 때 서버 @@ -87,20 +88,21 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { export type PlaceTarget = { add: true } | { add: false; id: string }; -/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id = 거래처 · 기준점 id */ export function openPlaceModal( projectId: string, target: PlaceTarget, addRoute?: (init: Partial) => string, ): void { const data = liveMilestone(projectId); - const editBase = !target.add && target.id === "base"; + const base = target.add ? undefined : data.bases.find((b) => b.id === target.id); + const editBase = !!base; const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; if (!target.add && !editBase && !route) return; const title = target.add ? "요소 추가 — 기준점 · 거래처" - : editBase - ? `기준점 — ${data.base.name || "기준점"}` + : base + ? `기준점 — ${base.name || "기준점"}` : `항목 정보 — ${route!.name || "거래처"}`; openModal({ title, @@ -118,20 +120,14 @@ export function openPlaceModal( }); sort.select.value = kind; sort.select.disabled = !target.add; - // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) - const baseOption = sort.select.querySelector('option[value="base"]'); - if (target.add && data.base.enabled && baseOption) { - baseOption.disabled = true; - baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)"; - } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); - name.input.value = editBase ? data.base.name : (route?.name ?? ""); + name.input.value = base ? base.name : (route?.name ?? ""); name.input.dataset.b08MilestoneAdd = "company"; const nameLabel = name.root.querySelector(".ui-field__label"); // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); - const old = editBase ? data.base : route; + const old = base ?? route; const place = placePicker( projectId, { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, @@ -163,11 +159,24 @@ export function openPlaceModal( const where = place.value(); const fresh = liveMilestone(projectId); let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 - if (kind === "base") { - Object.assign(fresh.base, where, { enabled: true, name: text, dir: null }); - if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }]; + if (kind === "base" && base) { + const b = fresh.bases.find((x) => x.id === base.id); + if (!b) return close(); + Object.assign(b, where, { name: text, dir: null }); + putMilestone(projectId, fresh); + if (moved(where)) segment = b.id; + } else if (kind === "base") { + // 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기) + fresh.bases.push({ + id: `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`, + name: text, + dir: null, + legs: [{ road: null, km: null }], + to: "site", + linked: false, + ...where, + }); putMilestone(projectId, fresh); - if (moved(where)) segment = "base"; } else if (route) { const r = fresh.routes.find((x) => x.id === route.id); if (!r) return close(); @@ -180,9 +189,9 @@ export function openPlaceModal( } else { if (!text) return name.setError("거래처 이름을 넣으세요."); // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) - const id = addRoute?.({ + addRoute?.({ name: text, - via_base: true, + to: "site", linked: false, dir: null, rows: rowValues(), @@ -198,12 +207,17 @@ export function openPlaceModal( label: "삭제", variant: "danger", onClick: async () => { - const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; + const label = base ? base.name || "기준점" : route?.name || "거래처"; if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); - if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; - else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 + if (base) { + fresh.bases = fresh.bases.filter((b) => b.id !== base.id); + // 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음) + for (const o of [...fresh.bases, ...fresh.routes]) + if (o.to === base.id) + Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] }); + } else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 putMilestone(projectId, fresh); close(); }, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index d4b9c85b6..b3acd5fdd 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -14,6 +14,7 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -131,28 +132,26 @@ export function routeView(projectId: string, routeId: string): MilestoneView { if (!r) return out; add(r.id, r.name || "거래처", r.lon, r.lat); if (r.linked === false) return out; - if (d.base.enabled && r.via_base) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬 add("start", "시점", start()?.[0], start()?.[1]); return out; }; const lines = (): Array> => { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); - const via = !!r && r.linked !== false && d.base.enabled && r.via_base; - return [segmentPath(routeId), ...(via ? [segmentPath("base")] : [])].filter( - (p) => p.length >= 2, - ); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2); }; const map = editMap(projectId, { hint: "", onMove: (key, lon, lat) => void reverseAddress(projectId, lon, lat).then((addr) => { const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((x) => x.id === key); + const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address: addr }); putMilestone(projectId, fresh); - void findSegmentKm(projectId, key === "base" ? "base" : routeId).then(() => drawMap()); + void findSegmentKm(projectId, key).then(() => drawMap()); }), }); const drawMap = (): void => map.set(pins(), lines()); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 456a44b57..4f97cc489 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -11,16 +11,22 @@ * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 - * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { PAVED, type MilestoneData, type SegKind } from "./B08_Quantity_Milestone_Calc"; -import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; +import { + PAVED, + migrateMilestone, + type MilestoneData, + type SegKind, +} from "./B08_Quantity_Milestone_Calc"; +import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { @@ -39,7 +45,7 @@ interface Payload { export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, - base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + bases: [], routes: [], }; @@ -97,9 +103,11 @@ export const routeImageKey = (routeId: string): string | null => { export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; -/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */ export function liveMilestone(projectId: string): MilestoneData { - return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); + return migrateMilestone( + clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY), + ); } /** 고친 한 벌을 캐시에 · 화면에 알림 */ @@ -214,7 +222,7 @@ export function roadOptions(kind: SegKind): Array<{ value: string; text: string return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } -/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */ const paths = new Map>(); export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; /** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ @@ -229,7 +237,7 @@ export async function findSegmentKm( image = true, ): Promise { const data = liveMilestone(projectId); - const owner = from === "base" ? data.base : data.routes.find((r) => r.id === from); + const owner = ownerOf(data, from); if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); if (!seg) return ""; @@ -253,9 +261,8 @@ export async function findSegmentKm( if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 - const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs; - if (legs?.[0]?.auto_km !== res.km) { - putAutoKm(fresh, from, seg.key, res.km); + if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) { + putAutoKm(fresh, from, res.km); putMilestone(projectId, fresh); } else changes.dispatchEvent(new Event("change")); if (image && seg.key === "route" && paths.has(from)) diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py index 2c83b6a93..f08f32187 100644 --- a/resources/tester/test_244_12_b08_milestone_base_view.py +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -16,6 +16,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) LONG = "경상남도 거창군 북상면 병곡리 968-31" @@ -58,6 +59,7 @@ def _clipped(note, box_w): def _lay(data, site=None): + data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1) return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index 956d4631d..e462cbf04 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -21,7 +21,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) B08 = "B08_Quantity/B08_Quantity_" @@ -90,29 +93,29 @@ def test_포장_도로_구분_거울(): def test_도로_구분_갈래_연결선_구간_끝(): - d = json.dumps(WITH_BASE) - off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + d = json.dumps(migrate_milestone(WITH_BASE)) + off = {**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}} + off = json.dumps(migrate_milestone(off)) got = _node( [CALC, LAYOUT], f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; -console.log(JSON.stringify([["g", "d", "base", "start"].map((f) => PAVED[segKind(D, f)]), - ["g", "base"].map((f) => segKind(O, f)), - ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), - segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), - segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), +console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D, f)]), + ["g", "b1"].map((f) => segKind(O, f)), + segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "b1", S), + segmentEnds(D, "start", S), segmentEnds(O, "b1", S), segmentEnds(O, "g", S), kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", ) # 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장 - assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "base"] - assert got[2] == [True, False, True, False] and got[3] == [False, False] - assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], + assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "direct"] + assert got[2] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], "to": [127.85, 35.66]} # fmt: skip - assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64] - assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], + assert got[3]["text"] == "d → 시점" and got[3]["to"] == [127.83, 35.64] + assert got[4] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], "to": [127.83, 35.64]} # fmt: skip - assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음 - assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 - assert got[10] == ["route", "base"] and got[11] == ["route"] + assert got[5] is None and got[6] is None # 시점 → 중점 = 손 입력 · 기준점 없음 + assert got[7]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 + assert got[8] == ["route", "base"] and got[9] == ["route"] def test_표_열_기준점_따라(): @@ -237,13 +240,13 @@ await loadMilestone("p1"); const a = await findSegmentKm("p1", "g"); await new Promise((r) => setTimeout(r, 10)); const afterRoute = CALLS.length; -const b = await findSegmentKm("p1", "base"); +const b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1 const writes = WRITES.n; -await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀 +await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀 const live = liveMilestone("p1"); console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n, - route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"), - routeLines: routePaths("g"), baseLines: routePaths("base"), + route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"), + routeLines: routePaths("g"), baseLines: routePaths("b1"), start: await findSegmentKm("p1", "start") }}));""" got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS) assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km" @@ -290,7 +293,7 @@ def test_화면_등록_모달_연결선_모달(): assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place assert "/quantity/milestone/reverse?lon=" in place # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 - assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram + assert "base: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 203e3c163..37577bf9e 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -14,10 +14,13 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) def _layout(data, site=None, mid_km=0.75): + data = migrate_milestone(data) # 254 옛 꼴(base · via_base) → bases · to(옛 기준점 = b1) src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8") body = ( f"\nconst d = {json.dumps(data, ensure_ascii=False)};" @@ -95,17 +98,17 @@ def test_기준점_기준_방위_선_글(): ), # fmt: skip ) cx, cy = _center(lay, "site") - bx, by = _center(lay, "base") + bx, by = _center(lay, "b1") assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽 gx, gy = _center(lay, "g") assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽 kx, _ = _center(lay, "k") assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} - assert labels[("g", "base")] == ["2차포장 23.8km"] - assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("g", "b1")] == ["2차포장 23.8km"] + assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"] assert labels[("k", "start")] == [] - assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31" + assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31" def test_경위도_있으면_방위각이_8방보다_앞섬(): diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 613e5d68d..c29c60cf8 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -18,6 +18,7 @@ if str(ROOT) not in sys.path: from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -58,8 +59,9 @@ def test_방위_좌표_자동(): # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 "routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)], } - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") sx, sy = _center(lay, "s") assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 cx, cy = _center(lay, "site") @@ -68,8 +70,9 @@ def test_방위_좌표_자동(): # 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽) data["base"] = {**base, "lon": 127.7, "lat": 35.7} data["routes"] = [_route("n", 127.7, 35.8)] - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") nx, ny = _center(lay, "n") assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) assert abs(nx - bx) < 1 and ny < by - 200 @@ -188,17 +191,17 @@ def test_선_글_바로_고침_모달과_한_벌(): "legs": [{"road": "1차포장", "km": 0.6}]}, "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0)," f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}})," - f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" + f" 'b1', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" ) assert got[0] == {"road": "2차포장", "km": None} assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None after = got[3] assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] - assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] + assert after["bases"][0]["legs"] == [{"road": "1차포장", "km": None}] assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} # 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도) (row,) = compute_milestone(after, 1500.0) diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 0b1b161e1..216b0aa41 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -5,9 +5,15 @@ import json import re import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -17,6 +23,7 @@ def _read(rel): def _layout(base, site): data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []} + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)" result = subprocess.run( @@ -27,7 +34,7 @@ def _layout(base, site): assert result.returncode == 0, result.stderr lay = json.loads(result.stdout) center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]} - return center["site"], center["base"] + return center["site"], center["b1"] BASE = {"enabled": True, "name": "기준", "address": "", "legs": []} @@ -60,6 +67,5 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): ] assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") - assert ( - "placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout - ) + assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout + assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 9e609a8ef..ce9f8815e 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -82,13 +82,13 @@ def test_실제_포인터_사건으로_팬(): def test_거래처_상자_항목_글(): data = { "site": {"mid_station_m": None, "mid_road": "비포장"}, - "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "bases": [], "routes": [ - {"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None, + {"id": "g", "name": "거창군청", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""}, {"item": " 장비 ", "dest": "mid", "note": ""}, {"item": "골재", "dest": "mid", "note": ""}]}, - {"id": "k", "name": "", "address": "", "via_base": True, "dir": None, + {"id": "k", "name": "", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, ], } # fmt: skip @@ -118,12 +118,12 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): site = modal[modal.index("export function openSiteInfo") :] assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site - # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만 base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") ] - assert "enabled: true," in base and 'label: "삭제"' in base - assert "fresh.base = { enabled: false," in base + assert "fresh.bases.push({" in base and 'label: "삭제"' in base + assert "fresh.bases = fresh.bases.filter((b) => b.id !== base.id);" in base assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui assert "content: panContent(vw, vh)" in ui and "panStart," in ui diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 30f715237..4f99399f2 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -24,7 +24,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) FAKE_KEY = "fake-kakao-key-0123456789" @@ -144,7 +147,8 @@ def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): def _data(base_enabled=True, base_ll=(127.85, 35.66)): - return { + """254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀.""" + return migrate_milestone({ "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None, "lon": base_ll[0], "lat": base_ll[1], "legs": []}, @@ -156,7 +160,7 @@ def _data(base_enabled=True, base_ll=(127.85, 35.66)): "lon": None, "lat": None, "legs": [{"road": None, "km": None}], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, ], - } # fmt: skip + }) # fmt: skip LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" @@ -179,27 +183,27 @@ def test_구간_채움(): two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)") assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}] # 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로 - put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)") + put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)") assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}] - assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] + assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") line = {ln["from"]: ln for ln in lay["lines"]} assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] - assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""] + assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""] assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 def test_사용자_값_우선_고르기_표시(): data = _data() data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}] - data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] + data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] a, b = compute_milestone(data, 1500.0) # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) # 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0 assert (b["total"], b["pick"]) == (0, False) - data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) + data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) a2, _ = compute_milestone(data, 1500.0) assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False) # TS 짝 거울 @@ -210,7 +214,7 @@ def test_사용자_값_우선_고르기_표시(): # 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장 saved = ms.Milestone.model_validate(data).model_dump() assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5 - assert saved["base"]["legs"][0]["road"] is None + assert saved["bases"][0]["legs"][0]["road"] is None assert ms.Leg.model_validate({"km": 1}).road == "2차포장" diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 8f0393f93..7fd521f39 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -19,6 +19,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" @@ -118,14 +119,14 @@ def test_표지_셋(): "routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True}, {"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( [LAYOUT], f"""const D = {d}; console.log(JSON.stringify([ mapMarks(D, "r1", [127.83, 35.64]), mapMarks(D, "r2", [127.83, 35.64]), - mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null), + mapMarks({{ ...D, bases: [] }}, "r1", null), ]));""", ) assert got[0] == [ diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index d444804b8..93982a914 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -9,7 +9,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, road_of # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, + road_of, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -87,7 +91,9 @@ def test_153_지도_보기_유지_상자_색_끌어_옮김(): assert var in block[: block.index("}")], kind ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui - assert 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui # 159 이름 + assert ( + 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + ) # 159 이름 def test_153_끌어_놓은_자리_레이아웃(): @@ -96,23 +102,24 @@ def test_153_끌어_놓은_자리_레이아웃(): "base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]}, "routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [], "rows": [], "pos": [-700, 300]}]} # fmt: skip - expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])" + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) + expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'b1' || b.id === 'r').map((b) => [b.id, b.x, b.y])" out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert out.returncode == 0, out.stderr - assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]] + assert sorted(json.loads(out.stdout)) == [["b1", 500, -40], ["r", -700, 300]] def test_154_연결_점_끌어_잇기(): ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui - assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui + assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site" assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 - assert "if (via === now) return;" in ui # 같은 연결 = 그대로 + assert "if (route.linked !== false && route.to === t) return;" in ui # 같은 연결 = 그대로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert ( 'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal @@ -145,7 +152,7 @@ def test_155_요소_추가_안_이은_거래처_수기_입력(): state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") - assert "if (!route || route.linked === false) return null;" in layout + assert "if (!owner || owner.linked === false) return null;" in layout # 254 거래처 · 기준점 def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): diff --git a/resources/tester/test_254_1_b08_milestone_bases.py b/resources/tester/test_254_1_b08_milestone_bases.py new file mode 100644 index 000000000..5cccd62e7 --- /dev/null +++ b/resources/tester/test_254_1_b08_milestone_bases.py @@ -0,0 +1,213 @@ +"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 · +기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 · +도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차). + +견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 · +철근 상회 → 시점(직접). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Haul import with_haul_routes # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + base_chain, + compute_milestone, + migrate_milestone, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(files, expr): + src = "\n".join(IMPORTS.sub("", _read(f)) for f in files) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(road, km): + return {"road": road, "km": km} + + +def _base(bid, to, legs, linked=True): + return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to, + "linked": linked} # fmt: skip + + +def _route(rid, to, legs, dest="start"): + return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs, + "rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip + + +CHAIN = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "bases": [ + _base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]), + _base("B", "site", [_leg(None, 0.6)]), + ], + "routes": [ + _route("g", "A", [_leg(None, 23.8)]), + _route("k", "B", [_leg(None, 61.2)], dest="mid"), + _route("c", "site", [_leg(None, 2.0)]), + ], +} + +OLD = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85, + "lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_옛_꼴_옮김_거울(): + new = migrate_milestone(OLD) + assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"] + b1 = new["bases"][0] + assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20]) + assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85 + assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [ + ("r1", "b1", False), + ("r2", "site", False), + ] + # 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로 + off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}}) + assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"] + assert migrate_milestone(new) == new # 새 꼴 = 그대로 + # TS 짝 — 옮긴 꼴 · 표 합이 같음 + calc = [B08 + "Milestone_Calc.ts"] + ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})") + assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"] + assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20] + assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone( + OLD, 1500.0 + ) + # 옛 표 합 그대로 — 2차 23.8 + 1차 0.6 + (a, b) = compute_milestone(OLD, 1500.0) + assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5) + + +def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + # 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴) + folder = tmp_path / "B08_Quantity" / "milestone" + folder.mkdir(parents=True) + (folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8") + data = ms.read_data(tmp_path) + assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1" + assert "base" not in data and "via_base" not in data["routes"][0] + + async def root(_pid): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone" + saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장 + stored = json.loads((folder / "milestone.json").read_text("utf-8")) + assert "base" not in stored and stored["bases"][0]["id"] == "b1" + assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0] + assert saved["table"] == compute_milestone(OLD, None) + # 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절 + assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B" + twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]} + assert client.put(url, json=twice).status_code == 422 + box = {**CHAIN, "bases": [_base("site", "site", [])]} + assert client.put(url, json=box).status_code == 422 + # 운반 자동 상자 = 시점으로(안 이음) + (auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"] + assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto + + +def test_기준점_둘_사슬_셈_거울(): + g, k, c = compute_milestone(CHAIN, None) + # 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값 + assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3) + assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2) + # 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장 + assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6) + # 시점으로 바로 = 포장 + assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0) + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c] + # 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈) + cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]} + g2, k2, _ = compute_milestone(cut, None) + assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6) + assert (k2["s2"], k2["total"]) == (61.2, 62.0) + assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None) + # 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차) + kinds = _node( + calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))" + ) + assert kinds == ["via", "via", "base", "direct", "mid"] + + +def test_돌기_막음(): + loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]} + # 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎) + assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"] + (g, *_) = compute_milestone(loop, None) + assert g["total"] == 26.8 + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone( + loop, None + ) + self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]} + assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"] + # 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎 + layout = [B08 + "Milestone_Layout.ts"] + d = json.dumps(CHAIN) + got = _node( + layout, + f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site')," + f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id)," + f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]", + ) + assert got[:4] == [True, False, False, True] + assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"] + + +def test_화면_기준점_여럿_등록_삭제(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert "fresh.bases.push({" in modal and 'to: "site",' in modal + assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음 + # 지운 기준점에 이은 상자 = 안 이음 + assert "if (o.to === base.id)" in modal + state = _read(B08 + "UI_Milestone_State.ts") + assert "migrateMilestone(" in state and "bases: []," in state + page = _read(B08 + "UI_Milestone.ts") + assert "data.bases.length > 0" in page and 'to: "site",' in page + for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts", + "UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts", + "UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip + text = _read(B08 + name) + assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text From c2d3b8ccd9f9e231eccd91eb82f9a0ea7c0b74f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:58:29 +0900 Subject: [PATCH 034/151] =?UTF-8?q?feat(table):=20=ED=91=9C=20=ED=8F=AD=20?= =?UTF-8?q?CSS=20=EB=B3=80=EC=88=98=20=C2=B7=20=EA=B3=A0=EC=B9=A0=20?= =?UTF-8?q?=EC=B9=B8=20=EC=98=85=EC=9D=80=20=ED=85=8C=EB=91=90=EB=A6=AC=20?= =?UTF-8?q?=EB=90=98=EC=82=B4=EB=A6=BC=20=C2=B7=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=AF=B8=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=ED=9A=8C=EC=83=89=20(258-1=20=C2=B7=20258-2=20=C2=B7=20258-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 템플릿 표 인라인 width → --dt-w · css width var(--dt-w) · 거리이정표 표 틀 폭 전체 - 고칠 수 있는 칸 td.is-can-edit outline 1px accent 28% (box-shadow 아님) - B05 구조물 조합 미지정 = 구조물 리스트 램프 회색 - 시험 기대값 고침(243_1 · 246_1 · 177_1 · ui_data_table) · 새 시험 258_1 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- .../B05_Profile_UI_Style_Structures.css | 6 +-- .../test_177_1_summary_unassigned_bg.py | 8 +-- .../tester/test_243_1_double_click_tables.py | 20 ++++--- .../tester/test_246_1_b09_bill_menu_simple.py | 5 +- .../tester/test_258_1_table_width_var.py | 52 +++++++++++++++++++ resources/tester/test_ui_data_table.py | 2 +- ui_template/ui_template_data_table.css | 8 +++ ui_template/ui_template_data_table.ts | 9 +++- 8 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_258_1_table_width_var.py diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 846d3623c..c3f163646 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -453,10 +453,10 @@ background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface)); } -/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */ +/* 미연결 — 옅은 회색 배경 + 회색 테두리(구조물 리스트 램프 색 · 밝음 · 어두움 같은 토큰 · 258-3). */ .b05-structure__summary-name:not(.is-assigned) { - border-color: var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + border-color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-text-muted) 12%, var(--color-surface)); } .b05-structure__summary .b05-route__irregular-btn { diff --git a/resources/tester/test_177_1_summary_unassigned_bg.py b/resources/tester/test_177_1_summary_unassigned_bg.py index bafa3cf4f..7af55cb66 100644 --- a/resources/tester/test_177_1_summary_unassigned_bg.py +++ b/resources/tester/test_177_1_summary_unassigned_bg.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험).""" +"""PLAN 177-1 → 258-3 — 구조물 조합 미연결 라벨 = 옅은 회색(구조물 리스트 램프 색) 배경 + 테두리(소스 시험).""" import os import re @@ -15,10 +15,10 @@ def _rule(sel: str) -> str: return m.group(1) -def test_미연결_배경_테두리_경고색_글자색_없음(): +def test_미연결_배경_테두리_회색_글자색_없음(): body = _rule(".b05-structure__summary-name:not(.is-assigned)") - assert re.search(r"border-color:\s*var\(--color-warning\)", body) - assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body) + assert re.search(r"border-color:\s*var\(--color-text-muted\)", body) + assert re.search(r"background:\s*color-mix\([^;]*--color-text-muted[^;]*--color-surface", body) assert not re.search(r"(^|[;\s])color:", body) diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 5629a735d..a261089b1 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -1,7 +1,7 @@ -"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움. +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 258-2 에서 옅게 되살림. - 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 -- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양) +- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(옅은 outline · 258-2) · 못 고치는 칸 · 옛 방식 표 = 없음 - B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble - 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 """ @@ -76,7 +76,7 @@ setTimeout(() => { def test_공용_표_더블클릭_Enter_F2(): out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) - assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음 + assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시(258-2) assert out["start"] == [] # 처음 = 글 상자 없음 assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 @@ -90,10 +90,18 @@ def test_공용_표_더블클릭_Enter_F2(): assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) -def test_고칠_칸_테두리_CSS_없음(): +def test_고칠_칸_테두리_CSS_옅게_테마_변수만(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") - assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림 + m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) + assert m + body = m.group(1) + assert ( + "outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas));" + in body + ) + assert "outline-offset: -1px;" in body # 칸 크기 불변 + assert "box-shadow" not in body # 빨강 이동 상자(box-shadow)와 안 겹침 + assert "#" not in body # 하드코딩 색 없음 def _sources(*folders: str) -> list[Path]: diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py index 450d2dd66..4df6d88a6 100644 --- a/resources/tester/test_246_1_b09_bill_menu_simple.py +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -16,11 +16,12 @@ ROOT = Path(__file__).resolve().parents[2] OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"] -def test_입력_칸_테두리_없음(): +def test_입력_칸_채움_색_td_테두리_옅게(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") body = css.split(".ui-data-table__input {")[1].split("}")[0] assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body - assert "box-shadow" not in body and "is-can-edit" not in css + assert "box-shadow" not in body + assert "td.is-can-edit {" in css # 258-2 — 고칠 칸 옅은 outline 되살림 def test_더함_하나_줄_삭제_연결_이름_셋(): diff --git a/resources/tester/test_258_1_table_width_var.py b/resources/tester/test_258_1_table_width_var.py new file mode 100644 index 000000000..7831b90d7 --- /dev/null +++ b/resources/tester/test_258_1_table_width_var.py @@ -0,0 +1,52 @@ +"""258-1 · 258-2 · 258-3 — 표 폭 CSS 변수 · 입력 칸 테두리 옅게 · 구조물 조합 미지정 회색(소스 시험). + +- 템플릿 표 인라인 = `--dt-w`(열 합) · css `width: var(--dt-w)` → 페이지 css 가 width 로 덮음 +- 거리이정표 표 = 틀 폭 100% +- 고칠 칸 td.is-can-edit = accent 28% outline · box-shadow 아님 +- B05 미지정 = 회색(text-muted) +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DT_CSS = ROOT / "ui_template" / "ui_template_data_table.css" +DT_TS = ROOT / "ui_template" / "ui_template_data_table.ts" + + +def _rule(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_표_폭_인라인_변수_css_width_변수(): + ts = DT_TS.read_text("utf-8") + assert '' in ts + assert 'style="width:${x}px"' not in ts # 인라인 width 가 페이지 css 를 이기지 않음 + body = _rule(DT_CSS.read_text("utf-8"), ".ui-data-table__table") + assert "width: var(--dt-w);" in body + assert "table-layout: fixed;" in body + + +def test_거리이정표_표_틀_폭_전체(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.css").read_text("utf-8") + body = _rule(css, ".b08-milestone > .ui-data-table .ui-data-table__table") + assert "width: 100%;" in body # 변수 규칙(0,1,0)보다 선택자가 구체적 + + +def test_입력_칸_테두리_옅게_outline(): + css = DT_CSS.read_text("utf-8") + body = _rule(css, ".ui-data-table__table td.is-can-edit") + m = re.search(r"var\(--color-accent\) (\d+)%", body) + assert m and 25 <= int(m.group(1)) <= 30 # 예전 60% → 옅게 + assert "outline: 1px solid" in body and "box-shadow" not in body + assert '"is-can-edit"' in DT_TS.read_text("utf-8") + + +def test_구조물_조합_미지정_회색(): + css = (ROOT / "B05_Profile" / "B05_Profile_UI_Style_Structures.css").read_text("utf-8") + body = _rule(css, ".b05-structure__summary-name:not(.is-assigned)") + assert "border-color: var(--color-text-muted);" in body + assert "--color-text-muted" in body.split("background:")[1] + assert "--color-warning" not in body diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index 70ab16487..6b61be801 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -108,7 +108,7 @@ def test_155_1_글자_14px_열_폭_같은_비율(r): assert abs(r["scale"] - 14 / 12) < 1e-9 w = r["widths"] assert '' in w - assert 'style="width:350px"' in w # 표 폭 = 112 + 98 + 140 + assert 'style="--dt-w:350px"' in w # 표 폭 = 112 + 98 + 140 (258-1 · css 변수) assert 'class="is-frozen is-edge" style="left:112px"' in w # 둘째 고정 열 left 도 같이 assert "font-size: var(--text-body-sm);" in r["css"] assert "--dt-row-h: 28px;" in r["css"] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index f17dc400a..ed9f05f32 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -33,6 +33,8 @@ } .ui-data-table__table { + /* 258-1 — 표 폭 = 열 합(--dt-w · 인라인) · 페이지 css 가 width 로 덮음 */ + width: var(--dt-w); table-layout: fixed; border-collapse: separate; border-spacing: 0; @@ -175,6 +177,12 @@ text-align: right; } +/* 고칠 수 있는 칸(PLAN 258-2 · editOnDouble) — 글일 때도 옅은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ +.ui-data-table__table td.is-can-edit { + outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas)); + outline-offset: -1px; +} + .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index afc3bc564..44c2e69d8 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,6 +17,7 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 + * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 258-2 · 옅은 outline) · * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) @@ -183,7 +184,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans } = more; + const { lv, spans, can } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -209,6 +210,7 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -241,6 +243,8 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; + /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시 · 258-2) */ + can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -356,6 +360,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), + ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); } @@ -363,7 +368,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `` : ""; return ( - `
${esc(o.caption)}
${cap}${colgroup}` + + `
${cap}${colgroup}` + `${headHtml(cols, left)}${body}${foot}
` ); } From a1c033c3cb1b5f4d2f5144f861aa7567ab765983 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:59:10 +0900 Subject: [PATCH 035/151] =?UTF-8?q?feat(b09):=20=EC=9A=B4=EB=B0=98?= =?UTF-8?q?=EA=B3=B5=20=EC=A4=84=20=3D=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=ED=96=89=201:1=20(253-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동 상자 = 옛 키 · 사용자 상자 = haul:r-{id} · 둘째 행 haul:r-{id}.{n} - 이름 = 항목 + 운반 · 수량 빈칸 · 연결 = 경로 ref(설계내역서 덮개가 이김) - B09 가 거리이정표 표(compute_milestone)를 읽어 행 · 거리(L1 포장 · L2 비포장)를 셈 덧값으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_Haul.py | 34 ++++++++++++ B09_Estimation/B09_Estimation_Bill_Items.py | 27 ++++++++-- B09_Estimation/B09_Estimation_Router.py | 36 ++++++++----- resources/tester/test_253_1_haul_rows.py | 60 +++++++++++++++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_253_1_haul_rows.py diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index 93a01af50..2eb88da81 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -127,3 +127,37 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict } ) return {**data, "routes": routes} + + +def route_bill_key(route: dict[str, Any], index: int = 0) -> str: + """거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` · + 한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침).""" + if route.get("haul"): + return bill_key(str(route["haul"])) + return f"haul:r-{route.get('id')}" + (f".{index}" if index else "") + + +def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]: + """거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] · + L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) · + user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀).""" + routes = {r.get("id"): r for r in data.get("routes") or []} + out, seen = [], set() + for row in table: + route = routes.get(row.get("route")) or {} + key = route_bill_key(route, int(row.get("index") or 0)) + if key in seen: + continue + seen.add(key) + item = str(row.get("item") or "").strip() + out.append( + { + "key": key, + "name": f"{item}운반" if item else "운반", + "ref": route.get("ref"), + "user": not route.get("haul"), + "L1": row.get("paved"), + "L2": row.get("unpaved"), + } + ) + return out diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index ec581bf7c..d8a80c3df 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -106,9 +106,12 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: def haul_items( - materials: dict[str, Any] | None, refs: dict[str, Any] | None = None + materials: dict[str, Any] | None, + refs: dict[str, Any] | None = None, + routes: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: - """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) · + """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + + 거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) · 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김).""" out = [] for g in haul_groups(materials): @@ -128,6 +131,24 @@ def haul_items( "why": WHY_LOGIC if logic else WHY_HAUL, } ) + for r in routes or []: + if not r.get("user"): + continue + ref = (refs or {}).get(r["key"]) + logic = (ref or {}).get("tab") == "logic" + out.append( + { + "key": r["key"], + "section": "3", + "name": r["name"], + "spec": "", + "unit": "", + "qty": None, + "note": "B08 거리이정표", + "ref": ref, + "why": WHY_LOGIC if logic else WHY_HAUL, + } + ) return out @@ -136,7 +157,7 @@ def bill_items(b08: dict[str, Any]) -> dict[str, Any]: "items": [ *earth_items(b08.get("earthwork")), *structure_items(b08.get("summary")), - *haul_items(b08.get("materials"), b08.get("haul_refs")), + *haul_items(b08.get("materials"), b08.get("haul_refs"), b08.get("haul_routes")), ] } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 05f8d0c3b..d23353b28 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -27,11 +27,13 @@ from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException from pydantic import BaseModel -from B08_Quantity.B08_Quantity_Haul import bill_key +from B08_Quantity.B08_Quantity_Haul import haul_rows +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone +from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun @@ -110,18 +112,27 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t return sheets, compute_summary(sheets, hopyo_lines(sheets)) -def haul_refs(root: Path) -> dict[str, Any]: - """거리이정표 운반 상자 단가연결(251-5) → `{설계내역서 줄 키: ref}` — 못 읽으면 빈 묶음.""" +def haul_routes(root: Path) -> list[dict[str, Any]]: + """거리이정표 행 → 운반공 줄(253-1 · `haul_rows` · 거리 L1 · L2 포함) — 못 읽으면 빈 목록.""" try: - routes = read_milestone(root).get("routes") or [] - except Exception: # noqa: BLE001 — 거리이정표 없음 = 연결 없음 - return {} - return {bill_key(r["haul"]): r["ref"] for r in routes if r.get("haul") and r.get("ref")} + data = read_milestone(root) + mid = site_info(root, data["site"]["mid_station_m"])["mid_default_m"] + return haul_rows(data, compute_milestone(data, mid)) + except Exception: # noqa: BLE001 — 거리이정표 없음 = 운반 연결 없음 + return [] + + +def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]: + """셈 덧값 — `haul_refs{줄 키: ref}`(251-5) · `haul_dist{줄 키: {L1, L2}}`(253-2 거리 채움).""" + return { + "haul_refs": {r["key"]: r["ref"] for r in rows if r.get("ref")}, + "haul_dist": {r["key"]: {"L1": r["L1"], "L2": r["L2"]} for r in rows}, + } def calc_user(root: Path) -> dict[str, Any]: - """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결(화면엔 안 보냄).""" - return {**store.read_user(root), "haul_refs": haul_refs(root)} + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄).""" + return {**store.read_user(root), **haul_user(haul_routes(root))} async def _demand(root: Path) -> list[str]: @@ -143,11 +154,12 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: ) book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) - inputs["bill"] = bill_items({**b08, "haul_refs": await asyncio.to_thread(haul_refs, root)}) + hauls = await asyncio.to_thread(haul_routes, root) + inputs["bill"] = bill_items({**b08, **haul_user(hauls), "haul_routes": hauls}) inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) - calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} + calc = {**user, **haul_user(hauls)} sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) if schedule_draft: schedule_draft.attach(inputs, sheets, user) @@ -176,7 +188,7 @@ async def estimation(project_id: UUID) -> dict[str, Any]: async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) user = await asyncio.to_thread(store.write_user, root, body.get("user")) - calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} + calc = {**user, **haul_user(await asyncio.to_thread(haul_routes, root))} await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) diff --git a/resources/tester/test_253_1_haul_rows.py b/resources/tester/test_253_1_haul_rows.py new file mode 100644 index 000000000..514dcf8b4 --- /dev/null +++ b/resources/tester/test_253_1_haul_rows.py @@ -0,0 +1,60 @@ +"""253-1 거리이정표 행 1:1 → B09 운반공 줄 — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}`.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B08_Quantity.B08_Quantity_Haul import haul_rows, route_bill_key # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402 + +LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}} +DATA = { + "site": {"mid_station_m": 2000, "mid_road": "비포장"}, + "base": {"enabled": False, "legs": []}, + "routes": [ + {"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False, + "legs": [{"road": "포장", "km": 25.1}], "rows": [{"item": "모래", "dest": "start"}]}, + {"id": "u1", "name": "석산", "ref": LOGIC, "via_base": False, + "legs": [{"road": "포장", "km": 10}, {"road": "비포장", "km": 0.8}], + "rows": [{"item": "혼합골재", "dest": "start"}, {"item": "깬돌", "dest": "mid"}]}, + ], +} # fmt: skip + + +def test_keys(): + assert route_bill_key({"id": "h-sand", "haul": "sand"}) == "haul:3" + assert route_bill_key({"id": "h-x", "haul": "remicon"}) == "haul:remicon" + assert route_bill_key({"id": "u1"}) == "haul:r-u1" + assert route_bill_key({"id": "u1"}, 1) == "haul:r-u1.1" + + +def test_rows_one_per_table_row(): + rows = haul_rows(DATA, compute_milestone(DATA, None)) + got = {r["key"]: r for r in rows} + assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"] + assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False) + assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == ( + "혼합골재운반", 10, 0.8) # fmt: skip + assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함 + assert got["haul:r-u1"]["ref"] == LOGIC + + +def test_bill_items_user_rows(): + rows = haul_rows(DATA, compute_milestone(DATA, None)) + extra = haul_user(rows) + items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)} + assert ( + "haul:3" in items and items["haul:3"]["name"] == "모래운반" + ) # 자동 = 총괄자재 묶음 그대로 + user = items["haul:r-u1"] + assert (user["section"], user["name"], user["qty"], user["ref"]) == ( + "3", + "혼합골재운반", + None, + LOGIC, + ) + assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC + assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8} From b342f8f7846a1b5e1718855bcbd5ea4fa8672026 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:00:21 +0900 Subject: [PATCH 036/151] =?UTF-8?q?feat(b09):=20=EC=9C=84=20=ED=83=AD=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=20=ED=95=9C=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C=20=EB=8B=A8=EA=B0=80=EC=97=B0=EA=B2=B0=20=EC=B9=B8=20?= =?UTF-8?q?=EB=9E=A8=ED=94=84=20(257-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위 탭 총괄설계내역서 · 설계내역서 = 「설계내역」 한 묶음(총괄 · 설계내역서) - 단가연결 칸 = 램프(초록 연결 · 노랑 없음) · 풍선 = 글 · 머리 「단가↵연결」 · 폭 44 - 시험 test_257_1 · test_181_1 · test_229_1 기대값 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Page.css | 24 +++++++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 3 +- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 20 ++++++- resources/tester/test_181_1_b09_tab_groups.py | 23 ++++---- .../test_229_1_b09_level_buttons_subbar.py | 2 +- resources/tester/test_257_1_b09_tabs_lamp.py | 58 +++++++++++++++++++ 6 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_257_1_b09_tabs_lamp.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 56f923c40..2d29972ac 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -30,9 +30,9 @@ visibility: hidden; } -/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ +/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈(257-1 머리 글 안 줄바꿈 글자도 살림) */ [data-b09-table] thead th { - white-space: normal; + white-space: pre-line; overflow-wrap: anywhere; } @@ -136,6 +136,26 @@ font-weight: var(--font-weight-bold); } +/* 257-1 설계내역서 단가연결 칸 = 램프(B08 구조물집계표 램프 꼴 · 글 숨김 · 풍선 = 글) — 노랑 = 없음 · 초록 = 연결 */ +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp { + font-size: 0; + text-align: center; +} + +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp.is-linked::before { + background: var(--color-success); +} + /* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 3dea31f92..6c5b8dd3b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -59,8 +59,7 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], - ["총괄설계내역서", ["total"]], - ["설계내역서", ["bill"]], + ["설계내역", ["total", "bill"]], ["일위대가", ["dg_list", "dg"]], ["단가산출", ["sg_list", "sg"]], ["환율및기초자료", ["basis"]], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index dd0322a7b..3bb3ec7be 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -34,10 +34,12 @@ export const buildBill: TabBuild = (ctx): BillTable => { const bill: Row = sheets.bill || { rows: [], totals: null }; // 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례) const raw: DataRow[] = bill.rows; + // 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N) + const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"); const rows: DataRow[] = raw.map((r: Row) => ({ ...r, name: r.no ? `${r.no} ${r.name}` : r.name, - connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "", + connect: linkable(r) ? `${linkText(r)} ▾` : "", })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ @@ -57,7 +59,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), - text("connect", "단가연결", 96), + { ...text("connect", "단가\n연결", 44), title: "단가연결" }, text("note", "비고", 200), ], rows, @@ -88,9 +90,21 @@ export const buildBill: TabBuild = (ctx): BillTable => { if (r.key !== "fee") return editCell(ctx, raw, i, key, v); setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } }); }, - cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null), + cellClass: (i, key) => { + const r = rows[i]; + const lamp = + key === "connect" && r && linkable(raw[i]) + ? `b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}` + : ""; + return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null; + }, tip: (i, key) => { const r = rows[i]; + if (r && key === "connect" && linkable(raw[i])) { + const auto = (r.auto_ref as Row | null)?.key ?? "빈칸"; + const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : ""; + return linkText(raw[i]) + back; + } if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)"; if (!r || !edited(r, key)) return null; const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`]; diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index 12f27f1d5..09b83bd94 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -1,4 +1,4 @@ -"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. +"""181-1 B09 탭 두 층 — 위 탭 8(203-1 연결 점검 뺌 · 257-1 설계내역 한 묶음) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. - 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 - 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) @@ -19,8 +19,7 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" # 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 EXPECTED = [ ["공사원가계산서", ["cost"]], - ["총괄설계내역서", ["total"]], - ["설계내역서", ["bill"]], + ["설계내역", ["total", "bill"]], ["일위대가", ["dg_list", "dg"]], ["단가산출", ["sg_list", "sg"]], ["환율및기초자료", ["basis"]], @@ -42,7 +41,7 @@ def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩(): # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 firsts = list(dict.fromkeys(out["of"])) assert firsts == [g[0] for g in EXPECTED] - assert len(firsts) == 9 + assert len(firsts) == 8 def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): @@ -60,26 +59,26 @@ const view = () => ({ }); bars.draw('dg'); const a = view(); -bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.top.children[2].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 bars.sub.children[0].click(); // 일위대가목록표 bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 const b = view(); bars.draw('sum_material'); bars.draw('dg'); -bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) -bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.top.children[6].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[5].click(); // 중기 = 본 적 없음 → 첫 아래 탭 bars.draw('cost'); const c = view(); -bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭 +bars.top.children[1].click(); // 설계내역 = 첫 아래 탭(총괄) bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 console.log(JSON.stringify({ a, b, c, picks })); """, ) a, b, c = out["a"], out["b"], out["c"] - assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [ - "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출" + assert len(a["top"]) == 8 and [t[0] for t in a["top"]][:5] == [ + "공사원가계산서", "설계내역", "일위대가", "단가산출", "환율및기초자료" ] # fmt: skip - assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5 + assert [t[2] for t in a["top"]] == ["false"] * 2 + ["true"] + ["false"] * 5 assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] assert not a["blank"] and a["role"] == "tablist" @@ -88,7 +87,7 @@ console.log(JSON.stringify({ a, b, c, picks })); ["자재단가대비표", "true", "compare"], ["재료비수량금액집계표", "false", "sum_material"], ] # fmt: skip - assert b["top"][7][2] == "true" and b["top"][3][2] == "false" + assert b["top"][6][2] == "true" and b["top"][2][2] == "false" # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None diff --git a/resources/tester/test_229_1_b09_level_buttons_subbar.py b/resources/tester/test_229_1_b09_level_buttons_subbar.py index 2d40334c7..50a605e4a 100644 --- a/resources/tester/test_229_1_b09_level_buttons_subbar.py +++ b/resources/tester/test_229_1_b09_level_buttons_subbar.py @@ -75,7 +75,7 @@ console.log(JSON.stringify({ assert out["a"]["levels"] == ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"] assert out["a"]["guideIn"] is True # 안내 글 = 단추 줄이 아니라 따로(표 아래로) assert out["guideTag"] == "P" and "ui-general-block__empty" in out["guideCls"] - assert out["subBlank"] # 하위 탭 없는 묶음 = 빈 보조 줄(숨은 단추 + 도구) + assert not out["subBlank"] # 257-1 설계내역 = 아래 탭 둘(총괄 · 설계내역서) · 빈 보조 줄 아님 assert out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림 assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로 assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음 diff --git a/resources/tester/test_257_1_b09_tabs_lamp.py b/resources/tester/test_257_1_b09_tabs_lamp.py new file mode 100644 index 000000000..4a15af2be --- /dev/null +++ b/resources/tester/test_257_1_b09_tabs_lamp.py @@ -0,0 +1,58 @@ +"""257-1 B09 위 탭 「설계내역」(총괄설계내역서 · 설계내역서 한 묶음) · 설계내역서 단가연결 칸 램프. + +- 위 탭 = `TAB_GROUPS` 「설계내역」[total, bill] · 옛 두 위 탭 없음 +- 단가연결 칸 = 줄 칸 class `b09-bill__lamp`(+ 연결 `is-linked`) · 풍선 = 글(대가N · 연결됨 · 단가연결) +- 머리 = 「단가↵연결」 · 폭 44 · 머리 CSS pre-line +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.test_184_3_b09_bill_menu import ROWS, SRC, _node, _ts # noqa: E402 + +JS = """ +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser() {} }; +const table = buildBill(ctx); +const at = (key) => table.rows.findIndex((r) => r.key === key); +const keys = ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"]; +const col = table.columns.find((c) => c.key === "connect"); +console.log(JSON.stringify({ + col: { label: col.label, width: col.width, title: col.title }, + cls: keys.map((k) => table.cellClass(at(k), "connect")), + tip: keys.map((k) => table.tip(at(k), "connect")), + other: table.cellClass(at("earth:4"), "qty"), + groups: TAB_GROUPS.map(([label, tabs]) => [label, tabs]), +})); +""" + + +def test_램프_칸_class_풍선_머리(): + body = JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body)) + assert out["col"] == {"label": "단가\n연결", "width": 44, "title": "단가연결"} + assert out["cls"] == [ + "b09-bill__lamp is-linked", # 자동 줄 · 호표 연결 + "b09-bill__lamp", # 연결 없음 = 노랑 + "b09-bill__lamp is-linked", # 손 줄 · 비고 대가3 + None, # 자재 줄 = 연결 대상 아님 + None, + None, # 묶음 줄 + ] + assert out["tip"] == ["연결됨", "단가연결", "대가3", None, None, None] + assert out["other"] is None + labels = [g[0] for g in out["groups"]] + assert "설계내역" in labels and "총괄설계내역서" not in labels and "설계내역서" not in labels + assert ["설계내역", ["total", "bill"]] in out["groups"] + + +def test_CSS_램프_초록_노랑_머리_줄바꿈(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert "td.b09-bill__lamp::before" in css and "var(--color-warning)" in css + assert "td.b09-bill__lamp.is-linked::before" in css and "var(--color-success)" in css + head = css.split("[data-b09-table] thead th {", 1)[1].split("}", 1)[0] + assert "white-space: pre-line" in head From 8752bb0d1a516513fed88db2de62d64bc45d82db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:01:22 +0900 Subject: [PATCH 037/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=9E=A5=EC=86=8C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=98=81=EA=B5=AC=20=EC=A0=80=EC=9E=A5=20=E2=80=94?= =?UTF-8?q?=20place/<=EC=83=81=EC=9E=90=20id>.png=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=92=A4=20=EB=B0=94=EB=80=90=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=EB=A7=8C=20=EC=BA=A1=EC=B2=98=20=C2=B7=20place=5Fimages=20(256?= =?UTF-8?q?-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- .../B08_Quantity_Milestone_Capture.py | 46 ++++-- B08_Quantity/B08_Quantity_Router_Milestone.py | 115 +++++++++++++- .../B08_Quantity_UI_Milestone_State.ts | 41 +++++ .../test_249_1_b08_milestone_capture_child.py | 2 +- .../test_256_1_b08_milestone_place_image.py | 146 ++++++++++++++++++ 5 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_256_1_b08_milestone_place_image.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index 7a3b50975..0ae895ca1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v 캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 · 키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가 드라이버 하위 프로세스를 못 만듦(249-1). +장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`. """ from __future__ import annotations @@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 TAIL = " — [그림 변경]으로 넣으세요." +PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌 _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) @@ -45,17 +47,22 @@ class CaptureError(Exception): """까닭 한 줄(키 · 예외 원문 없음).""" -def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: - """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" +def page_html( + key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None +) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail. + view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함).""" + w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT) + level = view["level"] if view else 7 data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" return f""" -
+
""" @@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]: """자식 프로세스 몸통 — 그림 찍어 `/.png` 저장 → {saved, reason}.""" key = os.environ.get(KAKAO_JS, "").strip() try: - png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"]) + png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"]) except CaptureError as error: return {"saved": False, "reason": str(error)} out_dir = Path(job["out"]) @@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]: def make_route_image( - out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] + out_dir: Path, + route_id: str, + lines: list[Any], + marks: list[dict[str, Any]], + view: dict[str, int] | None = None, + what: str = "경로 그림", + tail: str = TAIL, ) -> dict[str, Any]: """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" key = os.environ.get(KAKAO_JS, "").strip() if not key: return { "saved": False, - "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}", } if not any(lines) and not marks: - return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"} origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") - job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks} + job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view} with _lock: got = spawn({**job, "origin": origin}) if not got.get("saved"): - return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"} + return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"} return {"saved": True, "reason": None} +def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]: + """장소 그림(256-1) — 작은 지도 · 표지 하나 → `/<상자 id>.png`.""" + return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "") + + if __name__ == "__main__": print(json.dumps(capture(json.loads(sys.stdin.buffer.read())))) diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index e68cccdbc..9b883c87c 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -9,6 +9,10 @@ POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 + POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 · + 이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름 + GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/.png` + · `auto/.png` · `.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음) GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 지금 중점 측점(244-12 · 상자 작은 글씨) @@ -24,6 +28,10 @@ 그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만 +다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 · +GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms} +(캐시 깨기). 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). @@ -32,9 +40,11 @@ from __future__ import annotations import asyncio +import hashlib import json import math import os +import threading import urllib.error import urllib.parse import urllib.request @@ -42,7 +52,7 @@ from pathlib import Path from typing import Annotated, Any, Literal from uuid import UUID -from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, ValidationError, model_validator @@ -51,7 +61,7 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 -from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image +from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from common_util.common_util_initial_snapshot import design_route_csv_path @@ -69,6 +79,8 @@ PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" DATA_FILE = "milestone.json" AUTO = "auto" # 자동 그림 하위 폴더(244-11) +PLACE = "place" # 장소 그림 하위 폴더(256-1) +PRINTS = ".prints.json" # 그림 지문 {"place/": 해시} — 바뀐 것만 다시 캡처 IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" @@ -212,15 +224,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]: } +def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]: + """좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = "b1".""" + # ponytail: 단일 기준점 — 254-1 bases[] 합친 뒤 bases[].id 로 + boxes = [(r["id"], r.get("name") or "", r.get("lon"), r.get("lat")) for r in data["routes"]] + base = data.get("base") or {} + boxes.append(("b1", base.get("name") or "기준점", base.get("lon"), base.get("lat"))) + return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None] + + def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) auto = images(root, AUTO) + place = images(root, PLACE) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 "auto_images": auto, + "place_images": place, + "place_times": { + stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items() + }, } @@ -230,13 +256,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for sub in ("", AUTO): + for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})): for stem, name in images(root, sub).items(): - if stem not in keep: + if stem not in ids: (path / name).unlink(missing_ok=True) return payload(root, data) +def _print(*parts: Any) -> str: + """지문 — 좌표 · 이름 해시(좌표 원문은 안 남김).""" + return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16] + + +def _read_prints(root: str | Path) -> dict[str, str]: + try: + return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _set_print(root: str | Path, key: str, value: str | None) -> None: + """지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처).""" + with _prints_lock: + prints = _read_prints(root) + if prints.get(key) == value: + return + if value is None: + prints.pop(key, None) + else: + prints[key] = value + atomic_write_json(folder(root) / PRINTS, prints) + + +_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나 + + +def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict: + """장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}.""" + got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat}) + if got["saved"]: + _set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat)) + return got + + +def refresh_images(root: str | Path) -> None: + """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 · + 실패 = 다음 [저장]에 다시.""" + data = read_data(root) + prints = _read_prints(root) + have = images(root, PLACE) + for place_id, name, lon, lat in places(data): + if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have: + place_image(root, place_id, name, lon, lat) + + def _route_id(route: str) -> str: try: return Route(id=route).id @@ -260,9 +333,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]: @router.put(PATH) -async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: +async def milestone_save( + project_id: UUID, body: Milestone, tasks: BackgroundTasks +) -> dict[str, Any]: root = await _project_root(project_id) - return await asyncio.to_thread(save_data, root, body) + out = await asyncio.to_thread(save_data, root, body) + tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 + return out @router.get(PATH + "/site-address") @@ -375,6 +452,32 @@ async def milestone_image_auto( return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} +FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$" + + +@router.post(PATH + "/place-image") +async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict: + """장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/.png · 좌표는 저장 안 함.""" + place_id = _route_id(id) + root = await _project_root(project_id) + got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat) + if not got["saved"]: + return {"name": None, "time": None, "reason": got["reason"]} + name = f"{PLACE}/{place_id}.png" + time_ms = int((folder(root) / name).stat().st_mtime * 1000) + return {"name": name, "time": time_ms, "reason": None} + + +@router.get(PATH + "/file") +async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)): + """그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음).""" + root = await _project_root(project_id) + path = folder(root) / name + if not path.is_file(): + raise HTTPException(status_code=404, detail="그림이 없습니다.") + return FileResponse(path) + + KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" KAKAO_KEY = "KAKAO_REST_API_KEY" diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 456a44b57..4909cf736 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -13,6 +13,8 @@ * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` · + * [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -35,6 +37,8 @@ interface Payload { site: SiteInfo; images: Record; auto_images: Record; + place_images: Record; + place_times: Record; } export const EMPTY: MilestoneData = { @@ -204,6 +208,43 @@ export async function autoRouteImage( changes.dispatchEvent(new Event("change")); } +/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */ +export const placeImageKey = (placeId: string): string | null => { + const name = saved?.place_images?.[placeId]; + return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null; +}; + +/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */ +export async function capturePlace( + projectId: string, + placeId: string, + mark: { name: string; lon: number; lat: number }, +): Promise { + let reason: string | null; + try { + const res = await request<{ name: string | null; time: number | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`, + { method: "POST", body: JSON.stringify(mark) }, + ); + reason = res.reason; + if (res.name && saved) { + saved.place_images = { ...saved.place_images, [placeId]: res.name }; + saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() }; + } + } catch (error) { + reason = `장소 그림 캡처 요청 실패(${String(error)})`; + } + if (reason) showToast(reason, "warning"); + changes.dispatchEvent(new Event("change")); +} + +/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */ +export async function placeImageBlobUrl(projectId: string, placeId: string): Promise { + const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`; + const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`; + return URL.createObjectURL(await (await call(url)).blob()); +} + /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py index 2e1948356..3f303ed2f 100644 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]] MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}] if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로 - cap.page_html = lambda key, lines, marks: HTML + cap.page_html = lambda key, lines, marks, view=None: HTML print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) sys.exit(0) diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py new file mode 100644 index 000000000..53be8f62e --- /dev/null +++ b/resources/tester/test_256_1_b08_milestone_place_image.py @@ -0,0 +1,146 @@ +"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7). + +캡처는 가짜(make_place_image 자리)로: +① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함 +② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/.png · + GET 답 place_images · place_times +③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음 +④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음) +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-place" +DATA = { + "base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68}, + "routes": [ + {"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866}, + {"id": "r2", "name": "좌표 없음"}, + ], +} + + +class Fake: + """가짜 캡처 — 부른 상자 기록 · ok=False 면 실패.""" + + def __init__(self): + self.calls = [] + self.ok = True + + def __call__(self, out_dir, place_id, mark): + self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"])) + if not self.ok: + return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"} + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{place_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + +def _save(root, data): + return ms.save_data(root, ms.Milestone.model_validate(data)) + + +def _fake(monkeypatch): + fake = Fake() + monkeypatch.setattr(ms, "make_place_image", fake) + return fake + + +def test_장소_그림_페이지_작은_지도(): + html = cap.page_html( + "k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW + ) + assert "width:480px;height:320px" in html and "level: 4" in html + assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌 + assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로 + + +def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + got = ms.payload(tmp_path, ms.read_data(tmp_path)) + assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"} + assert set(got["place_times"]) == {"b1", "r1"} + assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values()) + assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG + prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8") + assert "127.9095" not in prints and "35.6866" not in prints # 해시만 + + +def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + fake.calls.clear() + ms.refresh_images(tmp_path) + assert fake.calls == [] # 안 바뀜 = 캡처 0 + moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]} + _save(tmp_path, moved) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["r1"] + fake.calls.clear() + (tmp_path / "B08_Quantity/milestone/place/b1.png").unlink() + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1"] + # 실패 = 지문 안 적음 → 다음에 다시 + fake.calls.clear() + fake.ok = False + _save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}}) + ms.refresh_images(tmp_path) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1", "b1"] + + +def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch): + _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움 + assert got["place_images"] == {} and got["place_times"] == {} + assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir()) + + +def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + # [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄) + assert client.put(base, json=DATA).status_code == 200 + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + assert client.get(base).json()["place_images"]["r1"] == "place/r1.png" + # 등록 · 끌기 뒤 화면이 부름 + fake.calls.clear() + mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5} + res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json() + assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0 + assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)] + fake.ok = False + bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json() + assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"} + assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400 + # 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음 + assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG + assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404 + for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"): + assert client.get(base + "/file", params={"name": name}).status_code == 422, name From eaf93376388669bf4c2c87d137af330d1884ce3d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:02:18 +0900 Subject: [PATCH 038/151] =?UTF-8?q?fix(b06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=E2=80=94=20=EA=B3=B5=EC=9A=A9=20=ED=8C=AC?= =?UTF-8?q?=20=EB=88=84=EB=A6=84=EB=A7=88=EB=8B=A4=20moved=20=ED=92=82=20?= =?UTF-8?q?=C2=B7=20=EC=95=94=20=EA=B2=BD=EA=B3=84=EC=84=A0=20=EB=82=98?= =?UTF-8?q?=EB=88=94=20=C2=B7=20=EA=B3=A0=EB=A6=84=20(259-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가운데 단추 팬 뒤 왼쪽 누름 click 삼킴 없앰(거리이정표 다이어그램 같은 줄) - 암 경계선 조각 고름 · 서버 pick_lines rock 짝 · 스키마 · 타입 rock Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/b_svg_zoom_pan.ts | 2 + B06_Section/B06_Section_Api_Types.ts | 2 +- B06_Section/B06_Section_Schema.py | 2 +- .../B06_Section_UI_Cross_Detail_Geom.ts | 11 ++ .../B06_Section_UI_Cross_Detail_Select.ts | 11 +- ...07_DesignDetail_Engine_Cross_Table_Edit.py | 11 +- resources/tester/helper_259_detail_region.cjs | 133 ++++++++++++++++++ resources/tester/test_259_detail_region.py | 115 +++++++++++++++ 8 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_259_detail_region.cjs create mode 100644 resources/tester/test_259_detail_region.py diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index bb293e404..951564e82 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -148,6 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { + // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) + moved = false; // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..7bda5f9f9 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -638,7 +638,7 @@ export type CrossCellSource = /** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ export interface CrossPickPiece { - line: "design" | "ground" | "spoil" | "structure"; + line: "design" | "ground" | "spoil" | "structure" | "rock"; start: [number, number]; end: [number, number]; } diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..05740c7ef 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel): class CrossPickPiece(BaseModel): """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" - line: Literal["design", "ground", "spoil", "structure"] + line: Literal["design", "ground", "spoil", "structure", "rock"] start: list[float] = Field(..., min_length=2, max_length=2) end: list[float] = Field(..., min_length=2, max_length=2) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index f137535dc..3f15d056d 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray | undefined): P const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); +/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */ +export function rockRuns( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Pt[][] { + const offset = design?.rock_boundary_offset_m; + if (design?.geometry_preset !== "rock" || typeof offset !== "number") return []; + return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset])); +} + /** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ export function pickLines( design: CrossDesign | undefined, @@ -53,6 +63,7 @@ export function pickLines( design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], + rock: rockRuns(design, samples), }; } diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 2a5aca824..b3d8442c4 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Detail_Select.ts - * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). @@ -79,6 +79,8 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + else if (el.classList.contains("b06-chart__rock-boundary")) + others.push({ line: "rock", points }); } const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ line: piece.structure ? "structure" : "design", @@ -102,6 +104,7 @@ const LINE_NAME: Record = { structure: "구조물 선", ground: "원지반선", spoil: "사토장선", + rock: "암 경계선", }; /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ @@ -111,7 +114,11 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie const [piece] = pieces; const from = piece.points[0]; const to = piece.points[piece.points.length - 1]; - if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + if ( + design && + piece.points.length === 2 && + (piece.line === "design" || piece.line === "structure") + ) return pieceLabel(design, { from: { offset: from[0], elevation: from[1] }, to: { offset: to[0], elevation: to[1] }, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index d522ab3f9..3cc85c50f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None: return area if area > 1e-9 else None +def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]: + """암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙).""" + offset = _num(design.get("rock_boundary_offset_m")) + if design.get("geometry_preset") != "rock" or offset is None: + return [] + return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)] + + def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: - """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), "spoil": [_polyline(design.get("spoil_fill_line"))], + "rock": rock_runs(design, samples), } diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs new file mode 100644 index 000000000..61139b6dd --- /dev/null +++ b/resources/tester/helper_259_detail_region.cjs @@ -0,0 +1,133 @@ +/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */ +function panCase(panStart) { + const listeners = {}; + const svg = { + addEventListener: (name, fn) => (listeners[name] ??= []).push(fn), + setAttribute: () => {}, + classList: { add: () => {}, remove: () => {} }, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }), + }; + const layer = { setAttribute: () => {} }; + Z.attachSvgZoomPan({ + svg, + layer, + widthPx: 600, + heightPx: 300, + pad: { left: 10, right: 10, top: 10, bottom: 10 }, + content: { x: -600, y: -300, width: 1800, height: 900 }, + panStart, + }); + const fire = (name, init = {}) => { + let stopped = false; + const event = { + button: 0, + clientX: 0, + clientY: 0, + pointerId: 1, + preventDefault: () => {}, + stopPropagation: () => (stopped = true), + ...init, + }; + (listeners[name] ?? []).forEach((fn) => fn(event)); + return stopped; + }; + const drag = (button) => { + fire("pointerdown", { button, clientX: 100, clientY: 100 }); + fire("pointermove", { button, clientX: 140, clientY: 120 }); + fire("pointerup", { button, clientX: 140, clientY: 120 }); + }; + const click = (x = 50) => { + fire("pointerdown", { clientX: x, clientY: 50 }); + fire("pointerup", { clientX: x, clientY: 50 }); + return fire("click", { clientX: x, clientY: 50 }); + }; + return { drag, click, fire }; +} +{ + const middle = panCase(); + middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick) + out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() }; + // 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과) + const left = panCase((event) => event.button === 1 || event.button === 0); + left.drag(0); + // 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함 + out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() }; +} + +/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */ +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const rockPts = G.rockRuns(design, samples)[0]; +const elements = [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el(["b06-chart__rock-boundary"], rockPts), +]; +const pieces = S.collectPieces(elements, axes); +out.pieces = pieces; +const rockPieces = pieces.filter((p) => p.line === "rock"); +out.rockLabel = S.selectionLabel(design, [rockPieces[1]]); +out.rockSource = S.selectionSource("length", [rockPieces[1]]); +out.rockLocal = T.localCellValue(out.rockSource, design, samples); +out.rockLength = G.pathLength(rockPieces[1].points); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py new file mode 100644 index 000000000..4a0c3b66e --- /dev/null +++ b/resources/tester/test_259_detail_region.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조. + +못박는 것 + - 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 · + 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. + - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · + 암 프리셋 아니면 다시 고름. + +./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": -1.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_259_detail_region.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 259-1 공용 팬 ──────────────────────────────────────────────────────── + + +def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out): + assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False} + + +def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out): + assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False} + + +# ── 259-1 암 경계선 나눔 · 고름 ─────────────────────────────────────────── + + +def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): + rock = [p for p in out["pieces"] if p["line"] == "rock"] + assert len(rock) == 3 + ends = sorted(round(p["points"][0][0], 4) for p in rock) + assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3) + # 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점) + design_ends = { + round(q[0], 3) + for p in out["pieces"] + if p["line"] == "design" + for q in (p["points"][0], p["points"][-1]) + } + assert round(ROCK_X, 3) in design_ends + + +def test_암_경계선_고름_값_저장_꼴(out): + length = 2 * ROCK_X + assert out["rockLength"] == pytest.approx(length, abs=1e-3) + assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m" + source = out["rockSource"] + assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" + patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) + assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock" + + +def test_암_경계선_서버_거울(out): + number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6) + assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]] + # 토사 프리셋이면 암 경계선 없음 → 다시 고름 + soil = {**DESIGN, "geometry_preset": "soil"} + assert pick_lines(soil, SAMPLES)["rock"] == [] + assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True) From 01bca00e3cb8a8b62a8d17c3cf43265e4c46efbd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:03:54 +0900 Subject: [PATCH 039/151] =?UTF-8?q?feat(b08):=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=81=B4=EC=8B=9C=EA=B0=84=20=EC=A7=9D=20?= =?UTF-8?q?=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=EC=B1=84=EC=9B=80=20(253-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B08_Quantity_Haul_Cycle.py · .ts 짝 — 건설품셈 8-2-8 덤프트럭 · 거창 25 단가산출 1308~1347행 재현(t1 5.33 · t2 101.23 · Cm 109.78) - 로직 입력 이름 채움 Cm · 사이클시간 · t2 · 운반거리 · 덮은 칸(own) 그대로 - B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움 · 덮개 ref 에 cycle · own 남김 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_Haul_Cycle.py | 102 +++++++++++++++++++ B08_Quantity/B08_Quantity_Haul_Cycle.ts | 93 ++++++++++++++++++ B09_Estimation/B09_Estimation_Sangeun.py | 17 +++- B09_Estimation/B09_Estimation_Store.py | 12 ++- resources/tester/test_253_2_haul_cycle.py | 114 ++++++++++++++++++++++ 5 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Haul_Cycle.py create mode 100644 B08_Quantity/B08_Quantity_Haul_Cycle.ts create mode 100644 resources/tester/test_253_2_haul_cycle.py diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.py b/B08_Quantity/B08_Quantity_Haul_Cycle.py new file mode 100644 index 000000000..02557fc77 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.py @@ -0,0 +1,102 @@ +"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + +⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + 조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본). + +식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림) + q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) + t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6 + L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0. +로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 · + `own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김. +기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름). +""" + +from __future__ import annotations + +import math +from typing import Any + +DEFAULTS: dict[str, float] = { + "L1": 0.0, + "L2": 0.0, + "V1": 30.0, + "V2": 35.0, + "V3": 10.0, + "V4": 15.0, + "T": 15.0, + "rt": 1.7, + "f": 1.0, + "qs": 1.72, + "k": 1.0, + "Es": 0.6, + "Cms": 37.4, + "t3": 0.8, + "t4": 0.42, + "t5": 0.5, + "t6": 1.5, +} +#: 로직 입력 이름 → 셈 결과 칸 +FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"} + + +def r2(value: float) -> float: + """소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 100 + 0.5) / 100 + + +def _div(a: float, b: float) -> float: + return a / b if b else 0.0 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]: + """사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}.""" + given = {k: _num(v) for k, v in (inputs or {}).items()} + v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()} + q = r2(_div(v["T"], v["rt"]) * v["f"]) + n = r2(_div(q, v["qs"] * v["k"])) + t1 = given.get("t1") + t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1 + l1, l2 = v["L1"], v["L2"] + t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60) + cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"]) + return { + "q": q, + "n": n, + "t1": t1, + "t2": t2, + "cm": cm, + "L": math.floor((l1 + l2) * 1000 + 0.5) / 1000, + } + + +def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]: + """연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로.""" + cycle = dict((ref or {}).get("cycle") or {}) + own = set((ref or {}).get("own") or []) + for name in ("L1", "L2"): + if name not in own and _num((dist or {}).get(name)) is not None: + cycle[name] = dist[name] + if "t1" not in own: + cycle.pop("t1", None) + return cycle + + +def fill_given( + ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None +) -> dict[str, Any]: + """로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로.""" + own = set(ref.get("own") or []) + got = cycle_time(cycle_inputs(ref, dist)) + given = dict(ref.get("given") or {}) + for name, slot in FILL.items(): + if name in names and name not in own: + given[name] = got[slot] + return given diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.ts b/B08_Quantity/B08_Quantity_Haul_Cycle.ts new file mode 100644 index 000000000..7d64079d4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B08_Quantity_Haul_Cycle.ts + * B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + * + * ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + * 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본). + * + * 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) · + * t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 · + * 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 · + * `own` = 사용자가 덮은 칸(그 값이 이김). + * ========================================================================== */ + +export const CYCLE_DEFAULTS: Record = { + L1: 0, + L2: 0, + V1: 30, + V2: 35, + V3: 10, + V4: 15, + T: 15, + rt: 1.7, + f: 1, + qs: 1.72, + k: 1, + Es: 0.6, + Cms: 37.4, + t3: 0.8, + t4: 0.42, + t5: 0.5, + t6: 1.5, +}; +/** 로직 입력 이름 → 셈 결과 칸 */ +export const CYCLE_FILL: Record = { + Cm: "cm", + 사이클시간: "cm", + t2: "t2", + 운반거리: "L", +}; + +export interface CycleResult { + q: number; + n: number; + t1: number; + t2: number; + cm: number; + L: number; +} +export interface CycleRef { + given?: Record | null; + cycle?: Record | null; + own?: string[] | null; +} +export type Dist = { L1?: number | null; L2?: number | null } | null | undefined; + +/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */ +export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100; +const div = (a: number, b: number): number => (b ? a / b : 0); +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */ +export function cycleTime(inputs: Record | null | undefined): CycleResult { + const v: Record = {}; + for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d; + const q = r2(div(v.T, v.rt) * v.f); + const n = r2(div(q, v.qs * v.k)); + const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es)); + const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60); + const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6); + return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 }; +} + +/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */ +export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record { + const cycle: Record = { ...(ref?.cycle || {}) }; + const own = new Set(ref?.own || []); + for (const name of ["L1", "L2"] as const) + if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name]; + if (!own.has("t1")) delete cycle.t1; + return cycle; +} + +/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */ +export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record { + const own = new Set(ref.own || []); + const got = cycleTime(cycleInputs(ref, dist)); + const given: Record = { ...(ref.given || {}) }; + for (const [name, slot] of Object.entries(CYCLE_FILL)) + if (names.includes(name) && !own.has(name)) given[name] = got[slot]; + return given; +} diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 3b7f74dd8..297f9de63 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -37,6 +37,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 @@ -475,13 +476,27 @@ class Solver: LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") +def _input_names(code: str) -> list[str]: + try: + return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []] + except mf.FormulaError: + return [] + + def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]: - """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)].""" + """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] · + 운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본).""" out = [] + dist = (user or {}).get("haul_dist") or {} for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함 ref = row.get("ref") or {} if ref.get("tab") != "logic" or not ref.get("code"): continue + if row["key"] in dist: + ref = { + **ref, + "given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]), + } src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")), "rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개 diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 07496606d..1016fcac6 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -126,9 +126,15 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None - return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), - "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, - "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), + "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, + "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸 + if isinstance(cycle, dict): + out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None} + own = ref.get("own") if isinstance(ref.get("own"), list) else [] + out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40] + return out return None diff --git a/resources/tester/test_253_2_haul_cycle.py b/resources/tester/test_253_2_haul_cycle.py new file mode 100644 index 000000000..59bac4dcb --- /dev/null +++ b/resources/tester/test_253_2_haul_cycle.py @@ -0,0 +1,114 @@ +"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 · +로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402 +from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402 + +XLSX = next( + (ROOT / "resources/knowledge/original/실무문서").glob( + "01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx" + ), + None, +) +#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤)) +CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3", + "AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs", + "Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4", + "AK1337": "t5", "AW1337": "t6"} # fmt: skip + + +@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음") +def test_거창_혼합골재_운반_재현(): + import openpyxl + + ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"] + inputs = {} + for cell, name in CELLS.items(): + text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######") + inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1)) + # 식 줄이 같은 꼴인지(t2 · Cm) + assert ( + "(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value + ) + assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value + assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8) + got = cycle_time(inputs) + assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33) + assert (got["t2"], got["cm"]) == (101.23, 109.78) + assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값 + + +CASES = [ + {"L1": 25.1, "L2": 0.8}, + {"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77}, + {"L1": 0, "L2": 0, "t1": 2.5, "V3": 0}, + {"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75}, +] + + +def test_거울_ts(): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + body = ( + f"\nconst cases = {json.dumps(CASES)};" + "\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));" + "\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};" + "\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});" + "\nconsole.log(JSON.stringify(got));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines()) + assert ts == [cycle_time(c) for c in CASES] + ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]} + assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8}) + + +def test_입력_이름_채움(): + ref = {"given": {"qt": 15}, "cycle": {}} + got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8}) + assert got == {"qt": 15, "Cm": 109.78} + assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9} + assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == { + "qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip + # 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값 + own = {"given": {"Cm": 50}, "own": ["Cm"]} + assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50} + mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]} + assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8} + + +def test_서버_셈_거리로_다시_채움(): + ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}} + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 109.78 + user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감 + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만 + + +def test_덮개_ref_사이클_남김(): + from B09_Estimation.B09_Estimation_Store import _clean_ref + + ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"}, + "own": ["Cm", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"] + rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}}) + assert rows[0]["ref"]["cycle"] == {"V1": 20} From f468a3a3fa21a456b5091d40e1118bd29d58b50d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:05:02 +0900 Subject: [PATCH 040/151] =?UTF-8?q?feat(b09):=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=95=B4=20=EC=98=A8=20=EC=A4=84=20=EB=B9=A8=EA=B0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=ED=83=AD=20=C2=B7=20=EB=B9=84=EA=B3=A0=20?= =?UTF-8?q?=EB=B9=88=20=EC=A4=84=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EC=B4=9D?= =?UTF-8?q?=EA=B3=B5=EC=82=AC=EB=B9=84=20=EC=95=84=EB=9E=98=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EC=A4=84=20(257-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(줄 누름 · Esc · 방향키로 풂) - 설계내역서 비고 「빈 줄 N」 지움(Py · TS 짝 · 「(제외금액)」 둠) - 총괄설계내역서 · 공사원가계산서 총공사비 = 템플릿 아래 고정 줄(줄 이동 누름 빠짐) - 시험 test_257_2 · 기대값 고침(145_3 · 145_4 · 182 · 185_1 · 242_1) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_Calc_Bill.py | 7 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 7 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 16 ++++- .../B09_Estimation_UI_Tabs_Groups.ts | 11 ++++ .../B09_Estimation_UI_Tabs_Total.ts | 15 ++++- resources/tester/test_145_3_b09_bill.py | 2 +- resources/tester/test_145_4_b09_total_calc.py | 2 +- resources/tester/test_182_b09_bill_level.py | 2 +- .../tester/test_185_1_b09_total_level.py | 16 ++++- .../tester/test_242_1_b09_bill_jump_range.py | 2 +- .../tester/test_257_2_b09_pick_blank_grand.py | 64 +++++++++++++++++++ 11 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 resources/tester/test_257_2_b09_pick_blank_grand.py diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 2e71e0ea0..6c7379b47 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -490,11 +490,10 @@ def compute_bill( gaps.extend(row.pop("_gaps", [])) row.pop("_home", None) row["no"] = "" - if row["kind"] == "title": - empty = f" 빈 줄 {row['blank']}" if row["blank"] else "" - row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty + if row["kind"] == "title": # 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로) + row["note"] = "(제외금액)" if row["key"] == "sec:G" else "" elif row["kind"] == "group": - row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else "" + row["note"] = "" if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2: up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음) seq[up] = seq.get(up, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 215c7dee6..50ad3e3fd 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -458,10 +458,9 @@ export function computeBill( delete row._gaps; delete row._home; row.no = ""; - if (row.kind === "title") - row.note = - (row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : ""); - else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : ""; + // 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로) + if (row.kind === "title") row.note = row.key === "sec:G" ? "(제외금액)" : ""; + else if (row.kind === "group") row.note = ""; if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) { // 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음) const up = row.parent; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 445f1c234..1f7c9faeb 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -62,7 +62,13 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; -import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +import { + isGroupTab, + lineHint, + pickClass, + renderGroupPanel, + unpickOnRelease, +} from "./B09_Estimation_UI_Tabs_Groups"; // 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) import.meta.glob("./draft/*_UI.ts", { eager: true }); @@ -274,13 +280,17 @@ export function renderB09Estimation(root: HTMLElement): void { const bill = (spec as BillTable).menu ? (spec as BillTable) : null; const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); if (bill) jumpTo(bill.rows, at); + // 257-2 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(그 줄 하나 · 줄 누름 · Esc 로 풂) + const range: [number, number] | null = !bill && at >= 0 ? [at, at] : null; + const rangeClass = range ? (i: number) => pickClass(range, i) : undefined; const table = makeTable( - bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined, + bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : rangeClass, ); + if (range) unpickOnRelease(table.root); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); - if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강) + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; const apply = (next: Row, fromServer: boolean): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 8618460f7..568979122 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -203,6 +203,17 @@ function listenKeys(): void { }); } +/** 257-2 이동해 온 줄 빨강(묶음 아닌 표 · 설계내역서 빼고) — 줄 누름 · 방향키 · Esc 로 풂(다시 그리지 않음) */ +export function unpickOnRelease(root: HTMLElement): void { + const clear = (): void => { + for (const tr of root.querySelectorAll("tr.is-pick")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end"); + }; + root.addEventListener("click", clear); + live = { root, unpick: clear }; + listenKeys(); +} + /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index c95485580..1ea78b13f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -223,8 +223,9 @@ export const buildTotal: TabBuild = (ctx): TabTable => { `순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`, { hand: hand("supply") }, ), - row("grand", "바.", "총공사비", sub.grand ?? null), ]; + // 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 몸 줄 아님 · 줄 이동 누름 없음) + const grand = row("grand", "바.", "총공사비", sub.grand ?? null); const safetyRow = rows.find((r) => r.key === "safety"); if (safetyRow && safety.a !== null && safety.a !== undefined) safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`; @@ -245,6 +246,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { text("candidate", "요율 출처", 260), ], rows, + total: grand, frozen: 2, rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), @@ -288,8 +290,7 @@ const COST_ROWS: Array<[string, string, string, string, string?]> = [ ["vat", "부가가치세", "", "", "vat"], ["contract", "도급공사비", "", "", "contract"], ["supply", "관급자재대", "", "", "supply"], - ["grand", "총공사비", "", "", "grand"], -]; +]; // 총공사비(grand) = 표 아래 고정 줄(257-2 · buildCost total) export const buildCost: TabBuild = (ctx): TabTable => { const t: Row = ctx.sheets.total || {}; @@ -314,6 +315,14 @@ export const buildCost: TabBuild = (ctx): TabTable => { level: !part ? 1 : name === "소계" ? 2 : 3, })); return { + // 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 줄 이동 누름 없음) + total: { + key: "grand", + group: "총공사비", + amount: cost.grand ?? null, + share: share.grand ?? null, + note: formula.grand ?? "", + }, columns: [ text("group", "구분", 96), text("part", "비목", 72), diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 87a11b144..ec6ef4aad 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -124,7 +124,7 @@ def test_제목_합_빈_줄과_원가계산서_이음(): assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G" assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0 assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수 - assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"] + assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음 # 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None assert sheets["bill"]["sums"] == { "material": None, diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py index ad95a5763..42b6e9ac2 100644 --- a/resources/tester/test_145_4_b09_total_calc.py +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -191,7 +191,7 @@ t = buildTotal(ctx()); t.onEdit(at(t.rows, "care"), "rate", ""); out.careGone = "care" in user.estimate.rates; t = buildTotal(ctx()); -out.grandBlank = t.rows[at(t.rows, "grand")].amount; +out.grandBlank = t.total.amount; // 257-2 총공사비 = 표 아래 고정 줄 t.onEdit(at(t.rows, "safety"), "hand", "1,000"); out.base = user.estimate.safety_base; const c = buildCost(ctx()); diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 122959fd6..6b9129080 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -121,7 +121,7 @@ def test_묶음_금액_자손_합_공종_합_그대로(): rows = _rows(sheets) g2, g1 = rows["hand:g2"], rows["hand:g1"] assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60) - assert (g2["blank"], g2["note"]) == (1, "빈 줄 1") + assert (g2["blank"], g2["note"]) == (1, "") # 257-2 비고 빈 줄 없음 assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄) assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == "" # 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈) diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index 896400301..5ba5c8e07 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -21,7 +21,8 @@ BODY = f""" const user = {{ estimate: {{}} }}; const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]); -console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }})); +const T = buildTotal(ctx), C = buildCost(ctx); +console.log(JSON.stringify({{ total: pack(T), cost: pack(C), fixed: {{ total: T.total, cost: C.total }} }})); """ @@ -41,8 +42,9 @@ def _run() -> dict: def test_총괄_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]} - top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"] + top = ["direct", "net_cost", "da", "supply_price", "contract"] assert all(rows[k] == (1, "is-title") for k in top) + assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total) for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat", "supply"]: # fmt: skip assert rows[k] == (2, None), k @@ -51,8 +53,9 @@ def test_총괄_레벨(): def test_원가계산서_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]} - for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]: + for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply"]: assert rows[k] == (1, "is-title"), k + assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total) for k in ["material", "labor", "expense_sum"]: assert rows[k] == (2, None), k for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]: @@ -74,3 +77,10 @@ def test_CSS_옛_레벨_규칙_없음_공용_표로(): r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);", common, ) + + +def test_총공사비_아래_고정_줄_총괄_원가계산서(): + fixed = _run()["fixed"] + assert fixed["total"]["key"] == "grand" and fixed["total"]["name"] == "총공사비" + assert fixed["cost"]["key"] == "grand" and fixed["cost"]["group"] == "총공사비" + assert fixed["cost"]["amount"] == fixed["total"]["amount"] # 원가계산서 = 총괄 거울 diff --git a/resources/tester/test_242_1_b09_bill_jump_range.py b/resources/tester/test_242_1_b09_bill_jump_range.py index cf65f27a4..5f9b71b27 100644 --- a/resources/tester/test_242_1_b09_bill_jump_range.py +++ b/resources/tester/test_242_1_b09_bill_jump_range.py @@ -122,7 +122,7 @@ def test_Shell_배선_공용_표_스크롤만(): shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "if (bill) jumpTo(bill.rows, at);" in shell assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell - assert "table.focusRow(at, !bill)" in shell + assert "table.focusRow(at, false)" in shell # 257-2 모든 탭 빨강 = 스크롤만 common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") assert "focusRow: (index: number, mark?: boolean) => void;" in common assert "state.focus = mark ? index : null;" in common diff --git a/resources/tester/test_257_2_b09_pick_blank_grand.py b/resources/tester/test_257_2_b09_pick_blank_grand.py new file mode 100644 index 000000000..ef5fdd736 --- /dev/null +++ b/resources/tester/test_257_2_b09_pick_blank_grand.py @@ -0,0 +1,64 @@ +"""257-2 B09 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 없음 · 총공사비 = 표 아래 고정 줄. + +- 쉘 = 설계내역서 밖 · 묶음 아닌 탭에도 줄 범위 [at, at] 빨강(pickClass) · 줄 누름 · Esc · 방향키로 풂(unpickOnRelease) +- 비고 = 제목 줄 「(제외금액)」만 · 묶음 줄 빈칸(Py · TS 짝 · blank 수는 줄 값 그대로) +- 총괄 · 원가계산서 총공사비(grand) = `TabTable.total`(몸 줄에서 빠짐 · 줄 이동 누름 없음 — 185-1 시험이 값 확인) +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +B09 = ROOT / "B09_Estimation" + + +def test_쉘_모든_탭_빨강_배선(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "!bill && at >= 0 ? [at, at] : null" in shell + assert "pickClass(range, i)" in shell and "unpickOnRelease(table.root)" in shell + assert "table.focusRow(at, false)" in shell + + +def test_풂_줄_누름_Esc_빨강만_지움(): + out = run( + [COMMON, TABS, GROUPS], + """ +const root = document.createElement("div"); +const rows = [0, 1, 2].map((i) => { + const tr = document.createElement("tr"); + tr.dataset.i = String(i); + if (i === 1) tr.classList.add("is-pick", "is-pick-top", "is-pick-end"); + root.append(tr); + return tr; +}); +unpickOnRelease(root); +const before = rows.map((t) => t.className); +key("Escape"); +const afterEsc = rows.map((t) => t.className); +rows[1].classList.add("is-pick"); +root.click(); +const afterClick = rows.map((t) => t.className); +console.log(JSON.stringify({ before, afterEsc, afterClick })); +""", + RAW + + """ +E.prototype.querySelectorAll = function () { return this.all("TR").filter((t) => t.cls.has("is-pick")); }; +""", + ) + assert out["before"] == ["", "is-pick is-pick-top is-pick-end", ""] + assert out["afterEsc"] == ["", "", ""] + assert out["afterClick"] == ["", "", ""] + + +def test_비고_빈_줄_없음_Py_TS_짝(): + py = (B09 / "B09_Estimation_Calc_Bill.py").read_text("utf-8") + ts = (B09 / "B09_Estimation_Calc_Bill.ts").read_text("utf-8") + assert "빈 줄 {" not in py and not re.search(r"빈 줄 \$\{", ts) + assert 'row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""' in py + assert 'row.note = row.key === "sec:G" ? "(제외금액)" : ""' in ts From 5c1eeb759ba6da507889d78591d02ff1041accc0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:05:23 +0900 Subject: [PATCH 041/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=9C=EC=A0=90=20=EB=81=8C=EA=B8=B0=20=3D=20?= =?UTF-8?q?=EA=B8=B8=EC=B0=BE=EA=B8=B0=20=EB=8D=AE=EA=B0=9C(site.start=5Fp?= =?UTF-8?q?in)=20=C2=B7=20=EB=8B=BF=EB=8A=94=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EB=8B=A4=EC=8B=9C=20=EA=B8=B8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=97=B4=20=EB=95=8C=20=EC=82=AC=EC=8A=AC?= =?UTF-8?q?=20=EA=B5=AC=EA=B0=84=20=EB=AA=A8=EB=91=90=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=95=88=EB=82=B4=20(255-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 7 +- B08_Quantity/B08_Quantity_Router_Milestone.py | 11 +- .../B08_Quantity_UI_Milestone_LegModal.ts | 3 +- .../B08_Quantity_UI_Milestone_Route.ts | 58 ++++++--- .../B08_Quantity_UI_Milestone_State.ts | 25 +++- .../test_255_2_b08_milestone_start_pin.py | 119 ++++++++++++++++++ 6 files changed, 194 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_255_2_b08_milestone_start_pin.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index a26bbab10..ece7882e5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -179,7 +179,12 @@ export interface MilestoneBase { } export interface MilestoneData { - site: { mid_station_m: number | null; mid_road: Road }; + site: { + mid_station_m: number | null; + mid_road: Road; + /** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */ + start_pin?: { lon: number; lat: number } | null; + }; bases: MilestoneBase[]; routes: MilestoneRoute[]; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 5ecf8d0de..e03790ba3 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -121,9 +121,15 @@ class Base(Place): linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤) +class LonLat(BaseModel): + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + class Site(BaseModel): mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) mid_road: Road = "비포장" + start_pin: LonLat | None = None # 255-2 길찾기용 시점 덮개(노선은 그대로) class Milestone(BaseModel): @@ -394,11 +400,6 @@ KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" KAKAO_KEY = "KAKAO_REST_API_KEY" -class LonLat(BaseModel): - lon: float = Field(ge=-180, le=180) - lat: float = Field(ge=-90, le=90) - - class Directions(BaseModel): origin: LonLat destination: LonLat diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 0f556f114..089b7c485 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -33,6 +33,7 @@ import { roadOptions, segmentPath, siteInfo, + startPoint, } from "./B08_Quantity_UI_Milestone_State"; const km = (text: string): number | null => { @@ -147,7 +148,7 @@ function openMidLeg(projectId: string): void { /** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */ export function openLegInfo(projectId: string, from: string): void { if (from === "start") return openMidLeg(projectId); - const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개 const seg = segmentEnds(liveMilestone(projectId), from, start()); if (!seg) return; openModal({ diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index b3acd5fdd..8c632231c 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -5,6 +5,8 @@ * * [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버) * 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157) + * 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 · + * 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함 * ========================================================================== */ import { @@ -26,7 +28,8 @@ import { putMilestone, routeImageKey, segmentPath, - siteInfo, + startPoint, + touchingSegments, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -120,20 +123,19 @@ export function routeView(projectId: string, routeId: string): MilestoneView { }); reset.dataset.b08MilestoneRevert = ""; - const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; const pins = (): MapPin[] => { const d = liveMilestone(projectId); + const start = startPoint(d); const r = d.routes.find((x) => x.id === routeId); const out: MapPin[] = []; const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => { - if (lon != null && lat != null) - out.push({ key, name: label, lon, lat, movable: key !== "start" }); + if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개) }; if (!r) return out; add(r.id, r.name || "거래처", r.lon, r.lat); if (r.linked === false) return out; for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬 - add("start", "시점", start()?.[0], start()?.[1]); + add("start", "시점", start?.[0], start?.[1]); return out; }; const lines = (): Array> => { @@ -142,24 +144,45 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2); }; + // 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」) + const status = el("p", { className: "b08-options__hint" }); + status.dataset.b08MilestoneRouteStatus = ""; + status.hidden = true; + /** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */ + const refind = async (ids: string[]): Promise => { + const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false))); + const bad = texts.filter((t) => t.includes(" — ")); + status.textContent = bad.join(" · "); + status.hidden = !bad.length; + drawMap(); + }; + /** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */ + const moveTo = async (key: string, lon: number, lat: number): Promise => { + const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat); + const fresh = liveMilestone(projectId); + if (key === "start") fresh.site.start_pin = { lon, lat }; + else { + const target = ownerOf(fresh, key); + if (!target) return; + Object.assign(target, { lon, lat, address }); + } + putMilestone(projectId, fresh); + await refind(touchingSegments(fresh, key)); + }; const map = editMap(projectId, { hint: "", - onMove: (key, lon, lat) => - void reverseAddress(projectId, lon, lat).then((addr) => { - const fresh = liveMilestone(projectId); - const target = ownerOf(fresh, key); - if (!target) return; - Object.assign(target, { lon, lat, address: addr }); - putMilestone(projectId, fresh); - void findSegmentKm(projectId, key).then(() => drawMap()); - }), + onMove: (key, lon, lat) => void moveTo(key, lon, lat), }); const drawMap = (): void => map.set(pins(), lines()); - let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음) + let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음) const ensurePath = (): void => { - if (asked || segmentPath(routeId).length) return; + if (asked) return; asked = true; - void findSegmentKm(projectId, routeId, false).then(() => drawMap()); + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length); + if (missing.length) void refind(missing); }; const root = el("section", { @@ -173,6 +196,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { el("div", { className: "b08-milestone__bar", children: [change, reset, file] }), ], }), + status, img, map.root, ], diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 4f97cc489..2a9044cfb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -22,6 +22,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { PAVED, + SITE_ID, migrateMilestone, type MilestoneData, type SegKind, @@ -67,6 +68,19 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; +/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */ +export const startPoint = (data: MilestoneData): [number, number] | null => { + const pin = data.site.start_pin; + return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null); +}; + +/** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */ +export function touchingSegments(data: MilestoneData, key: string): string[] { + const to = key === "start" ? SITE_ID : key; + const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false); + return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)]; +} + /** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ const siteAddresses = new Map(); @@ -188,10 +202,11 @@ export async function autoRouteImage( lines: Array>, ): Promise { // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice( - 0, - 2, - ); + const marks = mapMarks( + liveMilestone(projectId), + routeId, + startPoint(liveMilestone(projectId)), + ).slice(0, 2); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( @@ -239,7 +254,7 @@ export async function findSegmentKm( const data = liveMilestone(projectId); const owner = ownerOf(data, from); if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 - const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + const seg = segmentEnds(data, from, startPoint(data)); if (!seg) return ""; if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; diff --git a/resources/tester/test_255_2_b08_milestone_start_pin.py b/resources/tester/test_255_2_b08_milestone_start_pin.py new file mode 100644 index 000000000..bf149c7e4 --- /dev/null +++ b/resources/tester/test_255_2_b08_milestone_start_pin.py @@ -0,0 +1,119 @@ +"""255-2 거리이정표 경로 탭 — 시점 끌기 = 길찾기용 덮개(site.start_pin · 노선 그대로) · +표지 옮기면 그 자리에 닿는 구간(나가는 · 들어오는) 모두 다시 길찾기 · 열 때 사슬 구간 모두 선 · +실패 안내 · 끌기는 그림 캡처 안 부름.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +STUBS = """ +let cache = null; +const sent = []; +const API_BASE_URL = ""; +const showToast = () => undefined; +const readState = () => cache; +const writeState = (_key, d) => { cache = d; }; +const clearState = () => { cache = null; }; +const request = async (url, opts) => { + if (url.endsWith("/quantity/milestone")) + return { data: DATA, site: { length_m: 1000, mid_default_m: 500, start_lonlat: [127, 35], + mid_lonlat: null }, images: {}, auto_images: {} }; + sent.push(JSON.parse(opts.body)); + return { km: 1.5, reason: null, path: [] }; +}; +""" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _place(pid, to, lon, lat, **more): + return {"id": pid, "name": pid, "address": "", "dir": None, "lon": lon, "lat": lat, + "legs": [], "to": to, **more} # fmt: skip + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [_place("b1", "site", 127.5, 35.5), _place("b2", "b1", 127.6, 35.6)], + "routes": [ + _place("r1", "site", 127.2, 35.2, rows=[]), + _place("r2", "b1", 127.3, 35.3, rows=[]), + _place("r3", "b2", 127.4, 35.4, rows=[]), + _place("r4", "b1", 127.1, 35.1, rows=[], linked=False), + ], +} + + +def _run(script): + src = "".join( + IMPORTS.sub("", _read(f"B08_Quantity/B08_Quantity_{n}.ts")) + for n in ("Milestone_Calc", "Milestone_Layout", "UI_Milestone_State") + ) + code = f"const DATA = {json.dumps(DATA)};\n{STUBS}\n{src}\n{script}" + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=code, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_시점_덮개_길찾기(): + got = _run(""" +await loadMilestone("p"); +await findSegmentKm("p", "r1", false); +const d = liveMilestone("p"); +d.site.start_pin = { lon: 128.25, lat: 36.5 }; +putMilestone("p", d); +await findSegmentKm("p", "r1", false); +await findSegmentKm("p", "b1", false); +console.log(JSON.stringify({ sent, start: startPoint(liveMilestone("p")) })); +""") + dest = [s["destination"] for s in got["sent"]] + assert dest[0] == {"lon": 127, "lat": 35} # 덮개 없음 = 노선 첫 점 + assert dest[1] == dest[2] == {"lon": 128.25, "lat": 36.5} # 거래처 · 기준점 → 시점 = 덮개 + assert got["start"] == [128.25, 36.5] + + +def test_닿는_구간_모두(): + got = _run(""" +console.log(JSON.stringify(["start", "b1", "b2", "r1", "r3"].map((k) => touchingSegments(DATA, k)))); +""") + start, b1, b2, r1, r3 = got + assert start == ["b1", "r1"] # 시점에 들어오는 구간 + assert b1 == ["b1", "b2", "r2"] # 나가는 구간 + 들어오는 경로들(안 이은 r4 빠짐) + assert b2 == ["b2", "r3"] + assert r1 == ["r1"] and r3 == ["r3"] + + +def test_서버_모델_시점_덮개(): + got = ms.Milestone.model_validate({"site": {"start_pin": {"lon": 127.5, "lat": 35.5}}}) + assert got.model_dump()["site"]["start_pin"] == {"lon": 127.5, "lat": 35.5} + assert ms.Milestone().model_dump()["site"]["start_pin"] is None + with pytest.raises(ValidationError): + ms.Milestone.model_validate({"site": {"start_pin": {"lon": 500, "lat": 35}}}) + + +def test_경로_탭_끌기_다시_길찾기(): + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "movable: true" in route and 'movable: key !== "start"' not in route + assert "fresh.site.start_pin = { lon, lat };" in route + assert "await refind(touchingSegments(fresh, key));" in route + assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음 + assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간 + assert "status.hidden = !bad.length;" in route # 실패 안내 + calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts") + assert "start_pin?: { lon: number; lat: number } | null;" in calc From 2584325b163e27a02067803a3e19df1d9c1a425d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:07:14 +0900 Subject: [PATCH 042/151] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=E2=80=94=20=EA=B8=B0=EC=A4=80=EC=A0=90=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=A0=90=20=C2=B7=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88=20=C2=B7=20=EC=84=A0=20=EA=B8=80=20?= =?UTF-8?q?=EB=91=90=20=EC=A4=84=20=C2=B7=20=EA=B1=B0=EB=A6=AC=20=EB=B9=84?= =?UTF-8?q?=EB=A1=80=20=EC=84=A0=20=EA=B8=B8=EC=9D=B4(254-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림) - 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px - 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320) - 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함 - 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 112 +++++++++++---- B08_Quantity/B08_Quantity_UI_Milestone.css | 9 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 70 ++++++---- .../test_244_12_b08_milestone_base_view.py | 6 +- .../test_244_2_b08_milestone_diagram.py | 11 +- .../tester/test_244_4_b08_milestone_modal.py | 4 +- .../test_244_5_b08_milestone_base_dir.py | 2 +- .../tester/test_244_7_b08_milestone_kakao.py | 7 +- .../test_244_9_b08_milestone_route_map.py | 3 +- .../test_250_1_b08_milestone_roads_width.py | 5 +- .../test_254_2_b08_milestone_diagram.py | 129 ++++++++++++++++++ 11 files changed, 289 insertions(+), 69 deletions(-) create mode 100644 resources/tester/test_254_2_b08_milestone_diagram.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index f2de77c52..2c99a0062 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -6,8 +6,12 @@ * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 - * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) - * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — + * 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례 + * 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 · + * 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔) + * 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리 + * 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 @@ -77,11 +81,9 @@ const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; const SITE = { w: 380, h: 106 }; const NODE = { w: 124, h: 58 }; const BOX = { w: 148, h: 58 }; -const R_BASE = 390; -const R_ROUTE = 280; const PAD = 48; -/** 바탕 반폭 · 반높이 — 기준점 · 공사지 둘레 거래처(같은 거리)가 어느 방위로 와도 담김 */ -const REACH = R_BASE; +/** 바탕 반폭 · 반높이 — 공사지 둘레 상자(짧은 구간 · 선 길이 최소쯤)가 어느 방위로 와도 담김 */ +const REACH = 390; export const BASE_VIEW: [number, number, number, number] = [ -(REACH + BOX.w / 2 + PAD), -(REACH + BOX.h / 2 + PAD), @@ -93,9 +95,17 @@ export const BASE_VIEW: [number, number, number, number] = [ export const GAP = 12; const LINE_GAP = 4; const INSET = 8; -/** 글 크기(px) — 화면 CSS 와 같음(이름 13 · 항목 · 선 글 12 · 작은 글씨 10) */ -const FONT = { label: 13, items: 12, km: 12, note: 10 }; +/** 글 크기(px) — 화면 CSS 와 같음(이름 · 항목 13(254 · 거래처 글과 같음) · 선 글 12 · 작은 글씨 10) */ +const FONT = { label: 13, items: 13, km: 12, note: 10 }; const KM_LINE = 16; +/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */ +export const ROW = 15; +const ROW_PAD = 13; +const NOTE_LINES = 3; + +/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */ +export const lineLen = (km: number): number => + Math.min(320, Math.max(100, 100 + 90 * Math.log10(1 + Math.max(0, km)))); export interface Rect { x: number; @@ -123,6 +133,36 @@ export function clip(text: string, maxW: number, size: number): string { return `${out}…`; } +/** 여러 줄(254) — 폭 넘으면 낱말(빈칸) 사이에서 · 긴 낱말은 글자에서 줄바꿈 · 최대 줄 넘으면 끝 줄 줄임표 */ +export function wrap(text: string, maxW: number, size: number, maxLines = NOTE_LINES): string[] { + const out: string[] = []; + let line = ""; + for (const word of text.trim().split(/\s+/).filter(Boolean)) { + const next = line ? `${line} ${word}` : word; + if (textW(next, size) <= maxW) { + line = next; + continue; + } + if (line) out.push(line); + line = word; + while (textW(line, size) > maxW) { + let cut = ""; + for (const ch of line) { + if (textW(cut + ch, size) > maxW) break; + cut += ch; + } + out.push(cut); + line = line.slice(cut.length); + } + } + if (line) out.push(line); + if (out.length <= maxLines) return out; + return [...out.slice(0, maxLines - 1), clip(out.slice(maxLines - 1).join(" "), maxW, size)]; +} + +/** 상자 높이(254) = 글 줄(항목 · 이름 · 작은 글씨) · 최소 BOX.h */ +const boxH = (rows: number): number => Math.max(BOX.h, rows * ROW + ROW_PAD); + /** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), @@ -162,7 +202,7 @@ export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect) segEnter(x1, y1, x2, y2, r) !== null; /** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ -const toEdge = (b: Rect, dx: number, dy: number): number => +const toEdge = (b: { w: number; h: number }, dx: number, dy: number): number => Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); /** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ @@ -181,12 +221,19 @@ export function placeDeg( return own.dir ? DIR_DEG[own.dir] : null; } +/** 구간 km(Calc `legKm` 과 같음 · 형 import 만) */ +const kmOf = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; + +/** 선 글 — 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km · 줄바꿈 글자로 나눔) */ export const legText = (legs: Leg[]): string[] => legs.map((leg) => { - const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만) - return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`; + const km = kmOf(leg); + return `${leg.road ?? "도로 구분 고르기"}\n${km == null ? "-" : String(km)}km`; }); +/** 구간 km 합(선 길이 · 254) */ +const legsKm = (legs: Leg[]): number => legs.reduce((sum, leg) => sum + Number(kmOf(leg) ?? 0), 0); + /** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ export const legTip = (leg: Leg): string => leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km @@ -309,6 +356,7 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia const t1 = Math.min(toEdge(a, dx, dy) + LINE_GAP / len, 0.5); const t2 = Math.max((segEnter(ax, ay, bx, by, end) ?? 1) - LINE_GAP / len, t1); const labels = legText(legs); + const rows = labels.flatMap((l) => l.split("\n")); return { from: a.id, to: b.id, @@ -320,8 +368,8 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia tips: legs.map(legTip), lx: (ax + bx) / 2, ly: (ay + by) / 2, - lw: labels.length ? Math.max(...labels.map((l) => textW(l, FONT.km))) + 8 : 0, - lh: labels.length * KM_LINE + 2, + lw: rows.length ? Math.max(...rows.map((l) => textW(l, FONT.km))) + 8 : 0, + lh: rows.length ? rows.length * KM_LINE + 2 : 0, }; } @@ -390,7 +438,11 @@ export function layoutMilestone( if (box.items) box.items = clip(box.items, inner, FONT.items); const pos = posOf(box.id); if (pos) [box.x, box.y] = pos; - for (let i = 0; !pos && out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { + // 254 선 길이가 짧아져 앞서 놓인 선 위에 앉지 않게 — 놓인 상자 · 놓인 선(가운데 ↔ 가운데) 둘 다 피함 + const blocked = (): boolean => + boxes.some((b) => hits(box, b, GAP)) || + pending.some(([f, t]) => segHits(...center(f), ...center(t), box)); + for (let i = 0; !pos && out && i < 400 && blocked(); i++) { box.x += out[0] * 4; box.y += out[1] * 4; } @@ -446,6 +498,9 @@ export function layoutMilestone( ); return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; }; + /** 잇는 곳 가운데 → 상자 가운데(254) = 잇는 곳 테두리 + 상자 테두리 + 선 길이(구간 km 비례) */ + const reachOf = (anchor: string, h: number, ux: number, uy: number, legs: Leg[]): number => + toEdge(byId.get(anchor)!, ux, uy) + toEdge({ w: BOX.w, h }, ux, uy) + lineLen(legsKm(legs)); /** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */ const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site"); @@ -467,15 +522,20 @@ export function layoutMilestone( degOf.set(b.id, deg); const [ax, ay] = center(anchor); const [ux, uy] = unit(deg); + const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : []; + const h = boxH(1 + notes.length); + const reach = reachOf(anchor, h, ux, uy, b.legs); put( { id: b.id, kind: "base", - x: ax + ux * R_BASE - BOX.w / 2, - y: ay + uy * R_BASE - BOX.h / 2, + x: ax + ux * reach - BOX.w / 2, + y: ay + uy * reach - h / 2, w: BOX.w, - h: BOX.h, + h, label: b.name || "기준점", + notes, + tip: b.address || undefined, }, [ux, uy], ); @@ -495,6 +555,8 @@ export function layoutMilestone( deg: number; legs: Leg[]; linked: boolean; + notes: string[]; + h: number; }> >(); const items = data.routes.map((r) => { @@ -509,6 +571,7 @@ export function layoutMilestone( const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); + const notes = r.address ? wrap(r.address, BOX.w - 2 * INSET, FONT.note) : []; groups.get(key)!.push({ id: r.id, label: r.name || "거래처", @@ -519,16 +582,19 @@ export function layoutMilestone( legs: r.legs, // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 linked: r.linked !== false && (r.to === "site" || anchor === r.to), + notes, + h: boxH((items ? 1 : 0) + 1 + notes.length), }); } for (const group of groups.values()) { group.forEach((g, k) => { const [ax, ay] = center(g.anchor); const [ux, uy] = unit(g.deg); - // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) - const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + // 나란히 = 방위에 직각 · 상자 직각 폭(무리에서 가장 높은 상자) + 여유만큼(④) + const tall = Math.max(...group.map((x) => x.h)); + const spread = Math.abs(ux) * tall + Math.abs(uy) * BOX.w + GAP; const off = (k - (group.length - 1) / 2) * spread; - const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) + const reach = reachOf(g.anchor, g.h, ux, uy, g.legs); const cx = ax + ux * reach - uy * off; const cy = ay + uy * reach + ux * off; put( @@ -536,12 +602,12 @@ export function layoutMilestone( id: g.id, kind: "route", x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + y: cy - g.h / 2, w: BOX.w, - h: BOX.h, + h: g.h, label: g.label, items: g.items, - notes: notesOf(BOX.w, g.address), + notes: g.notes, tip: g.address, }, [ux, uy], diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index fe859ca05..2c7509eae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -118,7 +118,8 @@ stroke: var(--color-success); } -.b08-milestone__box.is-route { +.b08-milestone__box.is-route, +.b08-milestone__box.is-base { cursor: pointer; } @@ -170,10 +171,10 @@ width: 80px; } -/* 거래처 상자 윗줄 = 항목(244-6) */ +/* 거래처 상자 윗줄 = 항목(244-6) · 글 크기 = 거래처 이름(254) */ .b08-milestone__items { fill: var(--color-text-secondary); - font-size: 12px; + font-size: 13px; } /* 상자 작은 글씨 = 주소 · 좌표(244-12) */ @@ -224,7 +225,7 @@ cursor: pointer; } -/* 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 · 공사지에 놓으면 연결 */ +/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */ .b08-milestone__connect { fill: var(--color-success); stroke: var(--color-surface); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index ee2fe6286..548c2b7f4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -10,12 +10,14 @@ * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달 * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) - * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) · + * 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km) + * 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소) * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, @@ -28,6 +30,7 @@ import { import { layoutMilestone, legAt, + makesLoop, ownerOf, panContent, panStart, @@ -201,13 +204,13 @@ export function createDiagram( }); }; - /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) · - * 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + - * 길찾기 한 번(km · 경로 그림) */ + /** 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254 · 테두리 안) · 끌어 기준점 상자 = 그 기준점으로 + * (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기 + * (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */ const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { - const cx = box.x + box.w; - const cy = box.y + box.h / 2; - const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 }); + const cx = box.x + 10; + const cy = box.y + 10; + const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 }); dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결")); dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸 dot.addEventListener("pointerdown", (down) => { @@ -229,6 +232,7 @@ export function createDiagram( const g = hit?.closest("[data-b08-milestone-box]"); const id = g?.dataset.b08MilestoneBox; if (id === "site" || id === "start" || id === "mid") return "site"; + if (id === box.id) return null; // 자기 = 취소 return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null; }; const move = (e: PointerEvent): void => { @@ -250,9 +254,16 @@ export function createDiagram( over?.classList.remove("is-drop-target"); const t = targetOf(e); const fresh = liveMilestone(projectId); - const route = fresh.routes.find((r) => r.id === box.id); + const route = ownerOf(fresh, box.id); if (!t || !route) return; if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로 + if (box.kind === "base" && makesLoop(fresh, box.id, t)) { + showToast( + "연결이 돌아 제자리로 옵니다(기준점 A → B → A) — 연결하지 않았습니다.", + "warning", + ); + return; + } route.to = t; route.linked = true; // 155 요소 추가 뒤 첫 이음 route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 @@ -371,15 +382,16 @@ export function createDiagram( }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); - if (box.kind === "route") g.append(connectDot(box, shift)); + if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift)); shift.append(g); if (box.kind === "site") shift.append(lineLayer); } - // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 + // 선 글은 상자 위에(가리지 않게 맨 끝) — 구간 줄마다 두 줄(254 · 도로 구분 / km) for (const ln of layout.lines) { // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) const x = ln.lx; - const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const count = ln.labels.reduce((n, l) => n + l.split("\n").length, 0); + const y = ln.ly - ((count - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, @@ -387,23 +399,27 @@ export function createDiagram( "text-anchor": "middle", "dominant-baseline": "central", }); + let first = true; ln.labels.forEach((label, i) => { - const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); - span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; - if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); - // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 let wait = 0; - span.addEventListener("click", (e) => { - e.stopPropagation(); - clearTimeout(wait); - wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); - }); - span.addEventListener("dblclick", (e) => { - e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 - clearTimeout(wait); - editLegAt(projectId, span, ln.from, i, mk); - }); - text.append(span); + for (const row of label.split("\n")) { + const span = svg("tspan", { x, dy: first ? 0 : 16 }, row); + first = false; + span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); + // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 + span.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(wait); + wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); + }); + span.addEventListener("dblclick", (e) => { + e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + clearTimeout(wait); + editLegAt(projectId, span, ln.from, i, mk); + }); + text.append(span); + } }); shift.append(text); } diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py index f08f32187..a5365f1c2 100644 --- a/resources/tester/test_244_12_b08_milestone_base_view.py +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -94,7 +94,7 @@ def test_시점_중점_선_상자_주소_좌표(): assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만 assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄 line = next(ln for ln in lay["lines"] if ln["from"] == "start") - assert line["to"] == "mid" and line["labels"] == ["비포장 1km"] + assert line["to"] == "mid" and line["labels"] == ["비포장\n1km"] # 254 두 줄 s, m = boxes["start"], boxes["mid"] assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 for b in (s, m): # 상자 = 공사지 안 @@ -108,7 +108,9 @@ def test_거래처_상자_항목_이름_주소(): _route("k", "E", via=False, address="거창읍")])) # fmt: skip boxes = {b["id"]: b for b in lay["boxes"]} assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g") - _clipped(boxes["g"]["notes"][0], boxes["g"]["w"]) + # 254 긴 주소 = 줄바꿈(낱말 사이) + 상자 세로 늘림(줄 × 15 + 13 · 최소 58) + assert boxes["g"]["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] + assert boxes["g"]["h"] == 4 * 15 + 13 and boxes["k"]["h"] == 58 assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] assert _node('clip("가나다", 100, 10)') == "가나다" assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 37577bf9e..ac4b3f24a 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -66,7 +66,7 @@ def test_공사지_컨테이너_시점_중점_선(): b = next(x for x in lay["boxes"] if x["id"] == bid) assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"] assert lay["lines"] == [ - {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]} + {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장\n0.75km"]} # 254 두 줄 ] @@ -78,8 +78,9 @@ def test_8방_자리_기준점_없음_공사지_기준(): ex, ey = _center(lay, "e") nx, ny = _center(lay, "n") wx, wy = _center(lay, "sw") + # 254 선 길이 = km 비례(빈 km = 최소 100) — 공사지 테두리(가로 190 · 세로 53)에서 잼 assert ex > cx + 200 and abs(ey - cy) < 1 - assert ny < cy - 200 and abs(nx - cx) < 1 + assert ny < cy - 150 and abs(nx - cx) < 1 assert wx < cx and wy > cy line = next(ln for ln in lay["lines"] if ln["from"] == "e") assert line["to"] == "start" # 기준점 없으면 시점으로 @@ -105,8 +106,8 @@ def test_기준점_기준_방위_선_글(): kx, _ = _center(lay, "k") assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} - assert labels[("g", "b1")] == ["2차포장 23.8km"] - assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("g", "b1")] == ["2차포장\n23.8km"] # 254 두 줄(도로 구분 / km) + assert labels[("b1", "start")] == ["1차포장\n0.6km", "비포장\n0.8km"] assert labels[("k", "start")] == [] assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31" @@ -130,7 +131,7 @@ def test_빈_방위_차례_같은_방위_나란히(): cx, cy = _center(lay, "c") dx, dy = _center(lay, "d") sx, sy = _center(lay, "site") - assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) + assert cy < sy - 150 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서) boxes = [b for b in lay["boxes"] if b["kind"] == "route"] for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index c29c60cf8..2cd00857e 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -63,7 +63,7 @@ def test_방위_좌표_자동(): lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") bx, by = _center(lay, "b1") sx, sy = _center(lay, "s") - assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + assert abs(sx - bx) < 1 and sy > by + 150 # 남 = 아래(254 빈 km = 최소 선 길이) cx, cy = _center(lay, "site") ex, ey = _center(lay, "e") assert ex > cx + 200 and abs(ey - cy) < 1 @@ -75,7 +75,7 @@ def test_방위_좌표_자동(): bx, by = _center(lay, "b1") nx, ny = _center(lay, "n") assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) - assert abs(nx - bx) < 1 and ny < by - 200 + assert abs(nx - bx) < 1 and ny < by - 150 def test_다음_지도_길찾기_주소(): diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 216b0aa41..53c593e4f 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -49,7 +49,7 @@ def test_좌표_없음_서쪽_옛_방위_무시(): def test_좌표_있음_공사지_기준_방위각(): (cx, cy), (bx, by) = _layout({**BASE, "dir": "W", "lon": 127.8, "lat": 35.8}, SITE) - assert abs(bx - cx) < 1 and by < cy - 200 # 북 = 위(옛 「서」 무시) + assert abs(bx - cx) < 1 and by < cy - 150 # 북 = 위(옛 「서」 무시 · 254 최소 선 길이) (cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE) assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른 # 시점 좌표 모름 = 서쪽 기본 diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 4f99399f2..9523bf302 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -189,9 +189,10 @@ def test_구간_채움(): # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") line = {ln["from"]: ln for ln in lay["lines"]} - assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] - assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""] - assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 + # 254 선 글 두 줄(도로 구분 / km) + assert line["r1"]["labels"] == ["2차포장\n23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] + assert line["b1"]["labels"] == ["도로 구분 고르기\n1.4km"] and line["b1"]["tips"] == [""] + assert line["start"]["labels"] == ["비포장\n0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 def test_사용자_값_우선_고르기_표시(): diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 7fd521f39..df41ff5d7 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -47,9 +47,10 @@ def _read(rel): def _node(rels, script): src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src + "\n" + script], + "--input-type=module-typescript", "-"], input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index 93982a914..c965c4822 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -115,7 +115,10 @@ def test_153_끌어_놓은_자리_레이아웃(): def test_154_연결_점_끌어_잇기(): ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui + # 254 기준점 상자에도 연결 점 + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site" assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 diff --git a/resources/tester/test_254_2_b08_milestone_diagram.py b/resources/tester/test_254_2_b08_milestone_diagram.py new file mode 100644 index 000000000..73f092158 --- /dev/null +++ b/resources/tester/test_254_2_b08_milestone_diagram.py @@ -0,0 +1,129 @@ +"""254-2 B08 거리이정표 다이어그램(사용자 162-4 · 5 · 6 · 10) — 기준점 상자 연결 점 · 주소 · +연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄 · +선 길이 = 거리 비례(로그 · 최소 · 최대). +""" + +import json +import math +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=src + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(km): + return {"road": None, "km": km} + + +def _route(rid, dir, km, to="site", address=""): + return {"id": rid, "name": rid, "address": address, "dir": dir, "to": to, + "legs": [_leg(km)] if km is not None else [], + "rows": [{"item": "장비", "dest": "start", "note": ""}]} # fmt: skip + + +def _base(bid, to, km, address="", lon=None, lat=None): + return {"id": bid, "name": bid, "address": address, "dir": None, "to": to, "lon": lon, + "lat": lat, "legs": [_leg(km)], "linked": True} # fmt: skip + + +def _data(bases, routes): + return {"site": {"mid_station_m": None, "mid_road": "비포장"}, "bases": bases, "routes": routes} + + +def _lay(data): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, null, 0.5)") + + +def _boxes(lay): + return {b["id"]: b for b in lay["boxes"]} + + +def test_선_길이_거리_비례_최소_최대(): + kms = [0, 1, 10, 23.8, 100, 1e6, -3] + got = _node(f"{json.dumps(kms)}.map(lineLen)") + want = [min(320, max(100, 100 + 90 * math.log10(1 + max(0, k)))) for k in kms] + assert all(abs(a - b) < 1e-9 for a, b in zip(got, want)) + assert got[0] == 100 and got[5] == 320 and got[6] == 100 # 최소 · 최대(너무 멀지 않게) + assert got[0] < got[1] < got[2] < got[3] < got[4] + # 동쪽 거래처 셋 — 공사지 테두리 → 상자 테두리 = 그 구간 선 길이 + lay = _lay(_data([], [_route("a", "E", 1), _route("b", "S", 60), _route("c", "W", None)])) + boxes = _boxes(lay) + site = boxes["site"] + a, b, c = boxes["a"], boxes["b"], boxes["c"] + assert abs((a["x"]) - (site["x"] + site["w"]) - got[1]) < 1e-6 + assert abs(b["y"] - (site["y"] + site["h"]) - _node("lineLen(60)")) < 1e-6 + assert abs(site["x"] - (c["x"] + c["w"]) - 100) < 1e-6 # 빈 km = 최소 + + +def test_기준점_사슬_자리_주소_높이(): + bases = [_base("A", "B", 4.5, address=LONG), _base("B", "site", 0.6, address="거창읍")] + lay = _lay(_data(bases, [_route("g", "N", 23.8, to="A")])) + boxes = _boxes(lay) + a, b, g = boxes["A"], boxes["B"], boxes["g"] + assert (a["kind"], b["kind"]) == ("base", "base") + # 기준점 상자 = 주소(줄바꿈) · 높이 = 이름 + 주소 줄 + assert a["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] and a["tip"] == LONG + assert a["h"] == 3 * 15 + 13 and b["notes"] == ["거창읍"] and b["h"] == 58 + # B = 공사지 서쪽(좌표 없음) · A = B 둘레(B 에 이음) · g = A 북쪽 + site = boxes["site"] + assert b["x"] + b["w"] < site["x"] and a["x"] + a["w"] < b["x"] + b["w"] + assert g["y"] + g["h"] < a["y"] + lines = {(ln["from"], ln["to"]) for ln in lay["lines"]} + assert {("g", "A"), ("A", "B"), ("B", "start"), ("start", "mid")} == lines + # 아주 긴 주소 = 최대 3줄 · 끝 줄 줄임표 + many = " ".join([LONG] * 4) + rows = _node(f'wrap("{many}", 132, 10)') + assert len(rows) == 3 and rows[-1].endswith("…") + assert all(_node(f'textW("{r}", 10)') <= 132 for r in rows) + + +def test_선_글_두_줄(): + lay = _lay(_data([_base("B", "site", 0.6)], [_route("g", "N", 23.8, to="B")])) + line = next(ln for ln in lay["lines"] if ln["from"] == "B") + assert line["labels"] == ["1차포장\n0.6km"] or line["labels"] == ["도로 구분 고르기\n0.6km"] + assert line["lh"] == 2 * 16 + 2 # 두 줄 높이 + two = _node("legText([{road: '1차포장', km: 0.6}, {road: '비포장', km: null}])") + assert two == ["1차포장\n0.6km", "비포장\n-km"] + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert 'for (const row of label.split("\\n"))' in ui # 줄마다 tspan + assert "span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;" in ui # 두 줄 다 그 구간 + + +def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소(): + ui = _read(B08 + "UI_Milestone_Diagram.ts") + dot = ui[ui.index("const connectDot") :] + dot = dot[: dot.index("return dot;")] + # 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안 + assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) + assert 'if (box.kind === "base" && makesLoop(fresh, box.id, t))' in dot + assert "if (id === box.id) return null;" in dot # 자기 = 취소 + assert "route.to = t;" in dot and "route.linked = true;" in dot + css = _read(B08 + "UI_Milestone.css") + items = css[css.index(".b08-milestone__items {") :] + assert "font-size: 13px;" in items[: items.index("}")] # 항목 = 거래처 글 + assert "items: 13" in _read(B08 + "Milestone_Layout.ts") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name From 29803ada06973aeabd00c9be125ce0860af148f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:07:38 +0900 Subject: [PATCH 043/151] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=91=9C=20=EC=9C=84=20=EB=B9=88=20CAD=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=84=A3=EC=9D=8C=20(260-1=20=C2=B7=20260?= =?UTF-8?q?-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 칸 design.structure_note_cad — 스키마 · 재계산 남김 · 세션 초안 structure-note-cads · [저장] patch - 상세 모달 글 상자 → 빈 CAD(cad_host bare · 높이 고정 · 닫으면 destroy · 고칠 때마다 초안) - 처음 열 때 칸이 없으면 옛 글 줄마다 Text · 없으면 횡단배수 주인 측점만 미리 넣음 - 시험 test_260_note_cad · helper_260_note_cad Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- A00_Common/b_page_state.ts | 2 + B06_Section/B06_Section_Api_Types.ts | 10 + B06_Section/B06_Section_Cross_Refresh.ts | 2 + B06_Section/B06_Section_Router_Design.py | 2 + B06_Section/B06_Section_Schema.py | 11 + B06_Section/B06_Section_UI_Cross_Detail.ts | 53 ++-- .../B06_Section_UI_Cross_Detail_Cad.ts | 286 ++++++++++++++++++ B06_Section/B06_Section_UI_Page_Persist.ts | 6 + resources/tester/helper_260_note_cad.cjs | 111 +++++++ resources/tester/test_260_note_cad.py | 157 ++++++++++ 10 files changed, 608 insertions(+), 32 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Cad.ts create mode 100644 resources/tester/helper_260_note_cad.cjs create mode 100644 resources/tester/test_260_note_cad.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 0399b7ad2..48ef93fe1 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ "structure-notes": { bucket: "draft", scope: "route" }, + /** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */ + "structure-note-cads": { bucket: "draft", scope: "route" }, /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..20acd01ee 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -474,6 +474,8 @@ export interface CrossDesign { pavement_suggested?: boolean; /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — CAD 저장 꼴 · 있으면 B07 이 글 대신 이 엔티티를 표 위에 놓음. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */ detail_table?: CrossDetailTable; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ @@ -625,6 +627,8 @@ export interface CrossSectionPatch { cut_rock_area_m2?: number; /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — 세션 초안 그대로. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ detail_table?: CrossDetailTable; } @@ -649,3 +653,9 @@ export interface CrossDetailTable { rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>; columns: Array<{ id: string; label: string }>; } + +/** [상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(`cad_host` · 엔티티 · 층) 그대로. */ +export interface CrossNoteCad { + entities: Record[]; + layers?: Record[]; +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 02bfa8a79..987e4cfdc 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [ "cut_slope_ratio_user", // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index adab8a3c5..294045754 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -155,6 +155,8 @@ USER_TOUCHED_KEYS = ( "cut_slope_ratio_user", # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + # 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..039df4ca8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -179,6 +179,15 @@ class CrossDetailTable(BaseModel): columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4) +class CrossNoteCad(BaseModel): + """[상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(엔티티 · 층) 그대로 · B07 이 표 위에 놓음.""" + + model_config = ConfigDict(extra="allow") + + entities: list[dict[str, Any]] = Field(default_factory=list, max_length=2000) + layers: list[dict[str, Any]] | None = None + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -246,6 +255,8 @@ class CrossSectionPatch(BaseModel): ford_pipe_length_m: float | None = Field(default=None, ge=0) # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. structure_note: str | None = Field(default=None, max_length=2000) + # 표 위 CAD 칸(260-1) — 엔티티가 빈 칸 = 비움(옛 글 대신 아무것도 안 놓음) + structure_note_cad: CrossNoteCad | None = None # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 detail_table: CrossDetailTable | None = None diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..a475ba9ed 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -4,8 +4,8 @@ * * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · * 칸 크기가 바뀌면 다시 그림) · - * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · - * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · + * 가운데 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 → + * [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) · * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). @@ -16,7 +16,7 @@ import "./B06_Section_UI_Style_Cross_Detail.css"; import { openModal } from "@ui/ui_template_modal"; -import { readState, writeState } from "../A00_Common/b_page_state"; +import { readState } from "../A00_Common/b_page_state"; import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; import type { CrossQuantitySheet, @@ -33,6 +33,7 @@ import { import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { mountNoteCad, stationNoLabel } from "./B06_Section_UI_Cross_Detail_Cad"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { @@ -52,9 +53,7 @@ export function setCrossDetailHost(next: CrossDetailHost): void { host = next; } -/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ - -const noteKey = (chainageM: number): string => chainageM.toFixed(2); +/* ── 옛 구조물 글 세션 초안(측점키 0.01 m → 글 · 새 글은 CAD 칸 260) ─────────────── */ /** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ export function readStructureNotes(projectId: string, routeId: number): Map { @@ -62,26 +61,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map [Number(key), String(text)])); } -function noteOf(section: CrossSection): string { - const routeId = host?.routeId(); - if (host && routeId !== null && routeId !== undefined) { - const draft = readState>("structure-notes", host.projectId, routeId); - const text = draft?.[noteKey(section.chainage_m)]; - if (text !== undefined) return text; - } - return section.design?.structure_note ?? ""; -} - -function writeNote(section: CrossSection, text: string): void { - // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 - if (section.design) section.design.structure_note = text; - const routeId = host?.routeId(); - if (!host || routeId === null || routeId === undefined) return; - const all = readState>("structure-notes", host.projectId, routeId) ?? {}; - all[noteKey(section.chainage_m)] = text; - writeState("structure-notes", all, host.projectId, routeId); -} - /* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ function cell( @@ -277,12 +256,21 @@ export function openCrossDetail(section: CrossSection): void { dialogClass: "b06-detail", wide: true, mount(body, close) { - const note = document.createElement("textarea"); - note.className = "ui-input b06-detail__note"; - note.rows = 3; - note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘"; - note.value = noteOf(section); - note.addEventListener("input", () => writeNote(state.section, note.value)); + // 표 위 빈 CAD(260-2) — 높이 고정 · 한 번만 만듦 · 닫으면 destroy + const note = document.createElement("div"); + note.className = "b06-detail__note"; + note.style.height = "180px"; + const routeId = host?.routeId(); + const stations = host?.detail()?.longitudinal.stations ?? []; + const noteCad = + host && routeId !== null && routeId !== undefined + ? mountNoteCad(note, section, () => state.section, { + projectId: host.projectId, + routeId, + station: stationNoLabel(section.chainage_m, inferStationInterval(stations)), + }) + : null; + note.querySelector(".cad-host")?.style.setProperty("min-height", "0"); const cad = document.createElement("div"); cad.className = "b06-detail__cad"; const tableBox = document.createElement("div"); @@ -369,6 +357,7 @@ export function openCrossDetail(section: CrossSection): void { stop: () => { resize.disconnect(); tablePick.detach(); + noteCad?.destroy(); }, }; // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts new file mode 100644 index 000000000..6633cfba7 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -0,0 +1,286 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Cad.ts + * 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21). + * + * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · + * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 + * (`_Engine_Cad_Table.cross_note_cad_entities`). + * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수 + * 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면. + * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. + * ========================================================================== */ + +import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; + +const noteKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */ +export function readStructureNoteCads( + projectId: string, + routeId: number, +): Map { + const raw = readState>("structure-note-cads", projectId, routeId); + return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad])); +} + +function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void { + // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 + if (section.design) section.design.structure_note_cad = cad; + const all = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + const next = { ...all, [noteKey(section.chainage_m)]: cad }; + writeState("structure-note-cads", next, at.projectId, at.routeId); +} + +/** 세션 초안 → 저장분 → 없음(null). */ +function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null { + const draft = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null; +} + +/* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */ + +const LAYER_ID = "기본"; +const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false }; +const FONT = 2.0; +const LINE = 3.0; +/** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */ +const NOTE_COLOR = "#e8edf4"; +const CULVERT_COLOR = "#ff7f7f"; + +const newId = (): string => crypto.randomUUID(); + +function textEntity( + label: string, + x: number, + y: number, + color: string, + align: "left" | "center" = "left", +): Record { + return { + id: newId(), + type: "Text", + lineColor: color, + lineWidth: 1, + layerId: LAYER_ID, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: align, + textColor: color, + fontSize: FONT, + fontFamily: "sans-serif", + }, + }, + }; +} + +/** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */ +export function legacyNoteEntities(note: string | undefined): Record[] { + const lines = (note ?? "") + .trim() + .split(/\r?\n/) + .map((line) => line.trimEnd()); + if (!lines.some(Boolean)) return []; + return lines.flatMap((line, index) => + line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [], + ); +} + +/* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */ + +const one = (value: number | null | undefined): string => + typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; +const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : ""); +const trim = (value: number): string => String(Math.round(value * 100) / 100); +/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */ +const bondOf = (form: string | null | undefined): string => + /찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? ""); +const BASIN_FORMS: Record = { I: "I형", L: "L형", U: "ㄷ형" }; + +/** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */ +function sideLine(side: CulvertSideSpec | undefined, title: string): string | null { + if (!side?.structure) return null; + const basin = side.structure === "집수정"; + const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form); + const height = basin ? side.basin_height_m : side.revet_height_m; + const length = basin ? side.basin_length_m : side.revet_length_m; + const spec = [form, `H=${one(height)}`].filter(Boolean).join(","); + return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`; +} + +/** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */ +function chuteLine(options: Record, fallback?: string): string | null { + const picked = Object.keys(options) + .map((key) => /^chute_add_(\d+)_structure$/.exec(key)) + .filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim()) + .sort((a, b) => Number(a[1]) - Number(b[1]))[0]; + const head = picked ? `chute_add_${picked[1]}_` : null; + const structure = head ? String(options[`${head}structure`]).trim() : fallback; + if (!structure) return null; + const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : ""; + const area = head ? Number(options[`${head}area`]) : NaN; + const spec = [structure, bond].filter(Boolean).join(","); + return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`; +} + +/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */ +export function stationNoLabel(chainageM: number, intervalM: number): string { + const safe = intervalM > 0 ? intervalM : 1; + let number = Math.floor((chainageM + 1e-6) / safe); + let rest = chainageM - number * safe; + if (rest >= safe - 0.05) { + number += 1; + rest = 0; + } + return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`; +} + +function shape(type: string, shapeData: Record): Record { + return { + id: newId(), + type, + lineColor: CULVERT_COLOR, + lineWidth: 1, + layerId: LAYER_ID, + shapeData, + }; +} + +/** + * 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관 + * (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적). + * 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) · + * 관유출부보강. + */ +export function culvertNoteEntities( + section: CrossSection, + station: string, + options: Record = {}, +): Record[] { + const culvert = section.culvert; + if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return []; + if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M) + return []; + const [cx, cy, radius] = [6.5, -9.0, 6.5]; + const inside = [ + "배수관", + `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, + `L=${metres(section.design?.pipe_length_m)}`, + ]; + const right = [ + station, + sideLine(culvert.inlet, "유입"), + sideLine(culvert.outlet, "유출"), + chuteLine(options, culvert.chute?.structure), + ].filter((line): line is string => !!line); + return [ + shape("Circle", { center: { x: cx, y: cy }, radius }), + shape("Line", { + startPoint: { x: cx, y: cy - radius }, + endPoint: { x: cx, y: cy - radius - LINE }, + }), + ...inside.map((line, index) => + textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"), + ), + ...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)), + ]; +} + +/** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */ +async function pipeOptionsAt( + projectId: string, + chainageM: number, +): Promise> { + const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points; + const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M); + return pipe?.options ?? {}; +} + +/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */ +async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise { + const stored = noteCadOf(section, at); + if (stored) return stored; + let entities = legacyNoteEntities(section.design?.structure_note); + if (!entities.length && section.culvert) { + const owner = section.culvert.chainage_m ?? section.chainage_m; + const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({})); + entities = culvertNoteEntities(section, at.station, options); + } + const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] }; + if (entities.length) writeNoteCad(section, cad, at); + return cad; +} + +/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */ + +export interface NoteCadPlace { + projectId: string; + routeId: number; + /** 미리 넣음 첫 줄(`stationNoLabel`). */ + station: string; +} + +/** + * [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · + * 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤). + */ +export function mountNoteCad( + box: HTMLElement, + first: CrossSection, + section: () => CrossSection, + at: NoteCadPlace, +): { destroy: () => void } { + let closed = false; + const cad = createCadHost({ + title: "표 위 CAD", + onChanged: (dirty) => { + if (!dirty || closed) return; + cad + .requestSave() + .then(({ drawing }) => { + if (!closed) + writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at); + }) + .catch(() => undefined); + }, + }); + box.append(cad.element); + cad.beginLoading(); + const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`; + void firstNoteCad(first, at).then((drawing) => { + if (closed) return; + const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER]; + cad.load( + { entities: drawing.entities, layers }, + null, + false, + {}, + { + recoveryScope: scope, + hostTitle: "표 위 CAD", + bare: true, + }, + ); + }); + return { + destroy: () => { + closed = true; + cad.destroy(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0d99b5e12..db7fb66a3 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -35,6 +35,7 @@ import { readShoulderExtends, } from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; +import { readStructureNoteCads } from "./B06_Section_UI_Cross_Detail_Cad"; import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; @@ -413,6 +414,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { patchFor(chainage).structure_note = note; }); + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 세션 초안 그대로(빈 엔티티 = 비움) + if (ctx.projectId && routeId !== null) + readStructureNoteCads(ctx.projectId, routeId).forEach((cad, chainage) => { + patchFor(chainage).structure_note_cad = cad; + }); // [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈 if (ctx.projectId && routeId !== null) readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => { diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs new file mode 100644 index 000000000..372e2a599 --- /dev/null +++ b/resources/tester/helper_260_note_cad.cjs @@ -0,0 +1,111 @@ +/* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음. + * `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내). + * 결과는 표준출력에 JSON 한 벌. 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { + "@ui/cad_host/cad_host": { createCadHost: () => ({}) }, + "../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({}) }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + throw new Error(`흉내 없는 모듈: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); + +const wall = (role, form, height, length) => ({ + role, + structure: "기슭막이", + revet_form: form, + revet_height_m: height, + revet_length_m: length, +}); +const culvert = (extra = {}) => ({ + type: "pipe", + chainage_m: 1433, + pipe_kind: null, + diameter_m: 1.2, + min_cover_m: 0.5, + inlet: wall("inlet", "돌쌓기(찰)", 2, 10), + outlet: wall("outlet", "돌쌓기(메)", 2, 10), + ...extra, +}); +const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({ + chainage_m: chainage, + station_id: "s", + samples: [], + culvert: spec, + design, +}); +const CHUTE = { chute_add_1_structure: "돌붙임", chute_add_1_bond: "찰붙임", chute_add_1_area: 10 }; + +/** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */ +const brief = (entities) => + entities.map((entity) => ({ + type: entity.type, + label: entity.shapeData.label ?? null, + align: entity.shapeData.options?.textAlign ?? null, + x: entity.shapeData.basePoint?.x ?? entity.shapeData.center?.x ?? null, + y: entity.shapeData.basePoint?.y ?? entity.shapeData.center?.y ?? null, + color: entity.lineColor, + layer: entity.layerId, + })); +const run = (sec, options) => brief(cad.culvertNoteEntities(sec, "NO.71+13", options)); + +const out = { + full: run(section(culvert()), CHUTE), + noLength: run(section(culvert(), {}), {}), + basin: run( + section( + culvert({ + inlet: { + role: "inlet", + structure: "집수정", + basin_form: "L", + basin_height_m: 1.7, + basin_length_m: 2, + }, + }), + ), + {}, + ), + chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), {}), + noSides: run( + section( + culvert({ + inlet: { role: "inlet", structure: "" }, + outlet: { role: "outlet", structure: "" }, + }), + ), + {}, + ), + none: run(section(undefined), CHUTE), + ford: run(section(culvert({ body: "ford" })), CHUTE), + hidden: run(section(culvert({ hidden_pipe: true })), CHUTE), + neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440), CHUTE), + legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")), + legacyEmpty: cad.legacyNoteEntities(" ").length, + station: [ + cad.stationNoLabel(1433, 20), + cad.stationNoLabel(1420, 20), + cad.stationNoLabel(1439.97, 20), + ], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py new file mode 100644 index 000000000..774dbc8d8 --- /dev/null +++ b/resources/tester/test_260_note_cad.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위. + +- 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch. +- 260-2 미리 넣음: 횡단배수 주인 측점만 · 관 · 유입 · 유출 · 도수로 · 빈 구조물 · 옛 글 옮김 + (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림). + +./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 + +HERE = Path(__file__).resolve().parent +TEXT = { + "id": "t1", + "type": "Text", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"label": "NO.71+13", "basePoint": {"x": 15, "y": -1.5}, "options": {}}, +} +CAD = {"entities": [TEXT], "layers": [{"id": "기본", "name": "기본"}]} + +# ── 260-1 칸 ──────────────────────────────────────────────────────────── + + +def test_CAD_칸은_patch_스키마를_지나_그대로_남는다(): + dumped = CrossSectionPatch(chainage_m=40.0, structure_note_cad=CAD).model_dump() + merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} + assert merged == {"structure_note_cad": CAD} + # 빈 엔티티 = 비움 — 걸러지지 않고 실림(옛 글 대신 아무것도 안 놓음) + empty = CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": []}).model_dump() + assert empty["structure_note_cad"] == {"entities": [], "layers": None} + # 안 보냄 = 안 바꿈 + assert CrossSectionPatch(chainage_m=40.0).model_dump()["structure_note_cad"] is None + + +def test_CAD_칸은_엔티티_수_상한(): + with pytest.raises(ValueError): + CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": [TEXT] * 2001}) + + +def test_CAD_칸은_재계산에서도_남는다(monkeypatch): + assert "structure_note_cad" in design_mod.USER_TOUCHED_KEYS + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + assert sections[0]["design"]["structure_note_cad"] == CAD + + +def test_브라우저가_CAD_칸을_저장_patch_에_싣는다(): + read = lambda rel: (PROJECT_ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + persist = read("B06_Section/B06_Section_UI_Page_Persist.ts") + assert "readStructureNoteCads(" in persist and ".structure_note_cad = cad" in persist + state = read("A00_Common/b_page_state.ts") + assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state + # 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록 + assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts") + # 모달 = 빈 CAD(캔버스만) · 글 상자 없음 + detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts") + assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail + assert "bare: true" in read("B06_Section/B06_Section_UI_Cross_Detail_Cad.ts") + + +# ── 260-2 미리 넣음 ───────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [entity["label"] for entity in entities if entity["type"] == "Text"] + + +def test_관_유입_유출_도수로_한_벌(out): + """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 관보호공 둘 · 관유출부보강.""" + full = out["full"] + assert [entity["type"] for entity in full[:2]] == ["Circle", "Line"] + assert _labels(full) == [ + "배수관", + "Φ1200m/m", + "L=22.0m", + "NO.71+13", + "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", + "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", + "관유출부보강(돌붙임,찰) 10m2", + ] + # 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층 + inside, right = full[2:5], full[5:] + assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])} + assert [e["y"] for e in right] == [-1.5, -4.5, -7.5, -10.5] + assert {e["align"] for e in right} == {"left"} + assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"} + + +def test_관_길이_없으면_L_뒤_빈칸(out): + assert _labels(out["noLength"])[2] == "L=" + # 도수로 상자가 없으면 관유출부보강 줄 없음 + assert not any(label.startswith("관유출부보강") for label in _labels(out["noLength"])) + + +def test_유입_집수정_은_형태_높이_길이(out): + assert "관보호공(유입) 집수정(L형,H=1.7) L=2.0m" in _labels(out["basin"]) + + +def test_도수로_옵션이_없으면_세트_구조만(out): + assert _labels(out["chuteFallback"])[-1] == "관유출부보강(도수로) m2" + + +def test_보호공을_안_고르면_관과_측점만(out): + assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"] + + +@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) +def test_횡단배수_주인_측점이_아니면_빈값(out, case): + """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · 이웃 측점 = 빈 도면.""" + assert out[case] == [] + + +def test_옛_글은_줄마다_Text_로_옮김(out): + legacy = out["legacy"] + assert _labels(legacy) == ["L형 측구", "집수정 보강"] + # 빈 줄은 자리만 — B07 옛 글 줄과 같은 높이(줄 3.0 · 가운데) + assert [e["y"] for e in legacy] == [-1.5, -7.5] and {e["x"] for e in legacy} == {0} + assert out["legacyEmpty"] == 0 + + +def test_측점_이름은_B07_꼴(out): + assert out["station"] == ["NO.71+13", "NO.71", "NO.72"] From 53a7b14f94cb4828e83293459a2f1b28c5dfbf4b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:08:59 +0900 Subject: [PATCH 044/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EA=B1=B0=EB=9E=98=EC=B2=98=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=EC=A0=90=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=B8=20=EC=9D=B4=EB=A6=84=20=3D=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EA=B8=80=20=ED=81=AC=EA=B8=B0=20=C2=B7=20=EC=86=8C?= =?UTF-8?q?=EC=9E=AC=EC=A7=80=20=EB=A8=B8=EB=A6=AC=20=EC=A4=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EA=B8=B0=EC=A4=80=EC=A0=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20=EC=86=8C=EC=9E=AC?= =?UTF-8?q?=EC=A7=80=20=EC=9C=84=20=EA=B0=80=EB=A1=9C=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B7=B8=EB=A6=BC=20=EC=BA=A1=EC=B2=98=20?= =?UTF-8?q?=EB=B6=80=EB=A6=84=20(255-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_UI_Milestone.css | 12 ++++++ .../B08_Quantity_UI_Milestone_Modal.ts | 23 ++++++---- .../B08_Quantity_UI_Milestone_Route.ts | 3 ++ .../test_244_5_b08_milestone_base_dir.py | 4 +- .../test_250_1_b08_milestone_roads_width.py | 2 +- .../test_255_3_b08_milestone_place_modal.py | 42 +++++++++++++++++++ 6 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_255_3_b08_milestone_place_modal.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index fe859ca05..e57efe263 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -216,6 +216,18 @@ font-weight: 600; } +/* 255-3 칸 이름 = 내용 글 크기 이상 · 한 줄 · 소재지(주소 · 지도) 위 가로선 */ +.b08-milestone__modal .ui-field__label { + font-size: var(--text-body-sm); + white-space: nowrap; +} + +.b08-milestone__place-field { + margin-top: var(--spacing-8); + padding-top: var(--spacing-16); + border-top: 1px solid var(--color-border); +} + /* 연결선 누름 자리 — 투명한 굵은 선(244-13) */ .b08-milestone__line-hit { stroke: transparent; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 476ede05d..ddc0dbd91 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -37,6 +37,7 @@ import { request } from "./B08_Quantity_UI_Hydro"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { + capturePlace, findSegmentKm, liveMilestone, putMilestone, @@ -58,8 +59,6 @@ export const ITEMS: Record<"route" | "base", string[]> = { const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); -const head = (text: string): HTMLElement => el("h4", { text }); - /** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */ export function toastKm(text: string): void { if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); @@ -79,7 +78,8 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } }))); item.input.placeholder = `예 ${ITEMS[k][0]}`; // 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149) - item.root.style.visibility = dest.root.style.visibility = k === "base" ? "hidden" : ""; + // 255-3 자리도 비움 — 기준점 이름이 분류 옆 첫 줄(두 줄로 안 꺾임) + item.root.style.display = dest.root.style.display = k === "base" ? "none" : ""; }; setKind(kind); dest.select.value = r.dest; @@ -160,16 +160,18 @@ export function openPlaceModal( const where = place.value(); const fresh = liveMilestone(projectId); let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 + let placed: string | undefined; // 256-1 등록 · 좌표 바뀜 = 장소 그림 캡처(뒤에서) if (kind === "base" && base) { const b = fresh.bases.find((x) => x.id === base.id); if (!b) return close(); Object.assign(b, where, { name: text, dir: null }); putMilestone(projectId, fresh); - if (moved(where)) segment = b.id; + if (moved(where)) segment = placed = b.id; } else if (kind === "base") { // 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기) + placed = `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; fresh.bases.push({ - id: `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`, + id: placed, name: text, dir: null, legs: [{ road: null, km: null }], @@ -186,11 +188,11 @@ export function openPlaceModal( rows: rowValues(), }); putMilestone(projectId, fresh); - if (moved(where)) segment = r.id; + if (moved(where)) segment = placed = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) - addRoute?.({ + placed = addRoute?.({ name: text, to: "site", linked: false, @@ -201,6 +203,12 @@ export function openPlaceModal( } close(); if (segment) void findSegmentKm(projectId, segment).then(toastKm); + if (placed && where.lon != null && where.lat != null) + void capturePlace(projectId, placed, { + name: text || (kind === "base" ? "기준점" : "거래처"), + lon: where.lon, + lat: where.lat, + }); }, }); ok.dataset.b08MilestoneInfo = "save"; @@ -231,7 +239,6 @@ export function openPlaceModal( className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), - head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, ); }, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 8c632231c..f7ea25721 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -20,6 +20,7 @@ import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { + capturePlace, deleteImage, findSegmentKm, hasUserImage, @@ -165,6 +166,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address }); + const label = fresh.bases.some((b) => b.id === key) ? "기준점" : "거래처"; + void capturePlace(projectId, key, { name: target.name || label, lon, lat }); // 256-1 장소 그림 } putMilestone(projectId, fresh); await refind(touchingSegments(fresh, key)); diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 216b0aa41..d3fa2290a 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -65,7 +65,9 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") ] - assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치 + assert ( + "placePicker(" in base and "방위는 이 자리로 자동" not in base + ) # 255-3 소재지 머리 줄 지움 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index 93982a914..51e5f93f5 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -65,7 +65,7 @@ def test_모달_상자_하나_항목_하나_수정_글(): leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") assert '"+ 줄"' not in modal and "same.rows" not in modal assert 'label: "고침"' not in modal + leg and modal.count('"수정"') >= 2 - assert 'item.root.style.visibility = dest.root.style.visibility = k === "base"' in modal + assert 'item.root.style.display = dest.root.style.display = k === "base"' in modal # 255-3 def test_폭_전체_지도_높이_한_값(): diff --git a/resources/tester/test_255_3_b08_milestone_place_modal.py b/resources/tester/test_255_3_b08_milestone_place_modal.py new file mode 100644 index 000000000..244906939 --- /dev/null +++ b/resources/tester/test_255_3_b08_milestone_place_modal.py @@ -0,0 +1,42 @@ +"""255-3 거리이정표 거래처 · 기준점 모달 — 칸 이름 ≥ 내용 글 · 「소재지 — 주소 찾기 · 지도에서 고르기」 +줄 지움 · 기준점 이름 한 줄(빈 칸 자리 비움) · 주소 · 지도 위 가로선 · +256-1 장소 그림 = 등록 · 좌표 바뀜 · 경로 탭 끌기 뒤 capturePlace.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _rule(css, selector): + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_모달_꼴(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "주소 찾기 · 지도에서 고르기" not in modal and 'el("h4"' not in modal + assert ( + 'item.root.style.display = dest.root.style.display = k === "base" ? "none" : "";' in modal + ) + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + label = _rule(css, ".b08-milestone__modal .ui-field__label") + assert "font-size: var(--text-body-sm);" in label # 입력 글(text-body-sm)과 같음 + assert "white-space: nowrap;" in label + assert "border-top: 1px solid var(--color-border);" in _rule(css, ".b08-milestone__place-field") + place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") + assert 'className: "b08-milestone__place-field"' in place + + +def test_장소_그림_캡처_부름(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert modal.count("segment = placed = ") == 2 # 기준점 · 거래처 좌표 바뀜 + assert "placed = addRoute?.({" in modal and "id: placed," in modal # 새 거래처 · 새 기준점 + assert "void capturePlace(projectId, placed, {" in modal + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "void capturePlace(projectId, key, { name: target.name || label, lon, lat });" in route From 16854e831eb617ebcfae2a8cfe474390fe4f5873 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:09:01 +0900 Subject: [PATCH 045/151] =?UTF-8?q?feat(b09):=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EB=8C=80=EB=B9=84=ED=91=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=8B=A8=EA=B0=80=20=EC=B4=88=EB=A1=9D=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=C2=B7=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=8B=A8=EA=B0=80=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EA=B8=B0=20(257-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고른 출처 단가 칸 = 바탕 색 대신 초록 테두리(안쪽 상자 그림자) - 적용 단가 칸 · 고른 출처 단가 칸 우클릭 = 「적용 단가 바꾸기」 → 출처 목록(왼쪽 누름 메뉴와 같은 항목) - 칸 우클릭 메뉴 한 자리(TabTable cellMenu · 쉘 배선) - 시험 test_257_3 · test_183_3 기대값 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Page.css | 5 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 11 +++ B09_Estimation/B09_Estimation_UI_Tabs.ts | 2 + B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 28 +++++--- .../tester/test_183_3_b09_applied_pick.py | 4 +- .../test_257_3_b09_compare_applied_menu.py | 67 +++++++++++++++++++ 6 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_257_3_b09_compare_applied_menu.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 2d29972ac..b82d9cb06 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -130,9 +130,10 @@ --pick-r: inset -2px 0 0 var(--color-danger); } -/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */ +/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) · 257-3 초록 테두리 + * (바탕 색 아님 — 입력 칸 테두리(outline)와 달리 안쪽 상자 그림자) */ .b09-estimation .ui-data-table__table tbody td.is-picked { - background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas)); + box-shadow: inset 0 0 0 2px var(--color-success); font-weight: var(--font-weight-bold); } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1f7c9faeb..d1f6797ea 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -246,6 +246,17 @@ export function renderB09Estimation(root: HTMLElement): void { const box = cell.getBoundingClientRect(); openMenuAt(box.left, box.bottom, items); }); + // 257-3 칸 우클릭 메뉴 — 누른 칸 자리에 B09 공용 메뉴(줄 메뉴가 없는 표) + if (spec.cellMenu) + made.root.addEventListener("contextmenu", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const column = cell ? spec.columns[cellColumn(cell)] : undefined; + const items = + cell && column && spec.cellMenu?.(Number(cell.parentElement?.dataset.i), column.key); + if (!items?.length) return; + event.preventDefault(); + openMenuAt(event.clientX, event.clientY, items); + }); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3) if (spec.tip) attachHoverInfo( diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 6c5b8dd3b..dbaa1c443 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -111,6 +111,8 @@ export interface TabTable { cellClass?: (index: number, key: string) => string | null | undefined; /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ choices?: (index: number, key: string) => MapContextMenuItem[] | null; + /** 칸 우클릭 메뉴(257-3 · 줄 메뉴가 없는 표 · 빈 것 = 안 엶) */ + cellMenu?: (index: number, key: string) => MapContextMenuItem[] | null; /** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */ level?: DataTableOptions["level"]; /** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 4eb8eb286..02ed55245 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -6,7 +6,8 @@ * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * * 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 → - * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · + * `pick` · 옛 수기 `applied` 지움 · 257-3 우클릭 「적용 단가 바꾸기」 = 같은 목록) · + * 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처 · 초록 테두리) · * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶) @@ -177,6 +178,14 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { else delete materials[key]; setUser({ ...user, materials }); }; + const chooseApplied = (r: Row): MapContextMenuItem[] => + appliedChoices(r, (s) => { + const cell = mine(r.key); + delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 + if (s) cell.pick = s; + else delete cell.pick; + put(r.key, cell); + }); return { columns: [ qty("no", "번호", 0), @@ -215,18 +224,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { else cell.quote = v; put(String(row.key), cell); }, - // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴 + // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조(257-3 초록 테두리) · 적용 단가 칸 누름 = 고르기 메뉴 cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null), - choices: (i, key) => { + choices: (i, key) => (key === "applied" && rows[i] ? chooseApplied(rows[i]) : null), + // 257-3 우클릭 「적용 단가 바꾸기」 → 출처 목록(적용 단가 칸 · 고른 출처 단가 칸 · 최저가여도 바꿈) + cellMenu: (i, key) => { const r = rows[i]; - if (key !== "applied" || !r) return null; - return appliedChoices(r, (s) => { - const cell = mine(r.key); - delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 - if (s) cell.pick = s; - else delete cell.pick; - put(r.key, cell); - }); + if (!r || (key !== "applied" && key !== `p:${r.applied_from}`)) return null; + const children = chooseApplied(r).filter((x): x is [string, () => void] => Array.isArray(x)); + return [{ label: "적용 단가 바꾸기", children }]; }, onCell: (i, key) => { const row = out[i]; diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py index dcc5b1c91..3e4f90e27 100644 --- a/resources/tester/test_183_3_b09_applied_pick.py +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -101,7 +101,7 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): assert 'editable: (_i, key) => key === "p:견적",' in compare assert "nextPick" not in base and 'key === "applied_from"' not in compare assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare - assert 'if (key !== "applied" || !r) return null;' in compare + assert 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare # 257-3 메뉴 둘 공용 assert "delete cell.applied" in compare shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") assert ( @@ -110,4 +110,4 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit css = _src("B09_Estimation/B09_Estimation_UI_Page.css") - assert "td.is-picked" in css and "var(--color-accent)" in css + assert "td.is-picked" in css and "var(--color-success)" in css # 257-3 초록 테두리 diff --git a/resources/tester/test_257_3_b09_compare_applied_menu.py b/resources/tester/test_257_3_b09_compare_applied_menu.py new file mode 100644 index 000000000..c631ea294 --- /dev/null +++ b/resources/tester/test_257_3_b09_compare_applied_menu.py @@ -0,0 +1,67 @@ +"""257-3 B09 자재단가대비표 적용 단가 — 초록 테두리 · 우클릭 「적용 단가 바꾸기」(출처 목록). + +- 고른 출처 단가 칸 = 초록 테두리(inset box-shadow · 바탕 색 아님) +- 우클릭 = 적용 단가 칸 · 고른 출처 단가 칸 → 「적용 단가 바꾸기」 하위 목록(왼쪽 누름 메뉴와 같은 항목 · 최저가여도 바꿈) +- 왼쪽 누름 메뉴(choices)는 그대로 +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +BASE = "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts" +PRELUDE = r""" +const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "견적"]; +const openQuotes = () => {}; +""" + +JS = """ +let user = {}; +const sheets = { compare: [ + { key: "m1", no: 1, name: "철근", spec: "D13", unit: "kg", lowest: 1000, lowest_from: "유통물가", + applied: 1000, applied_from: "유통물가", pick: null, + prices: { 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: null } }, +] }; +const t = buildCompare({ sheets, get user() { return user; }, setUser: (u) => (user = u) }); +const menu = t.cellMenu(0, "applied"); +const labels = menu[0].children.map((c) => c[0]); +menu[0].children[0][1](); // 물가정보 = 최저가 아닌 출처로 바꿈 +const afterPick = JSON.stringify(user.materials); +menu[0].children[1][1](); // 유통물가 = 최저가여도 고른 출처로 박음 +const afterLowest = JSON.stringify(user.materials); +console.log(JSON.stringify({ + label: menu[0].label, labels, afterPick, afterLowest, + onPicked: t.cellMenu(0, "p:유통물가")?.[0].label ?? null, + other: [t.cellMenu(0, "name"), t.cellMenu(0, "p:물가정보"), t.cellMenu(5, "applied")], + left: t.choices(0, "applied").length, leftOther: t.choices(0, "name"), + cls: [t.cellClass(0, "p:유통물가"), t.cellClass(0, "p:물가정보")], +})); +""" + + +def test_우클릭_적용_단가_바꾸기_출처_목록(): + out = run([TABS, BASE], JS, PRELUDE) + assert out["label"] == "적용 단가 바꾸기" + assert out["labels"] == ["물가정보 1,200", "✓ 유통물가 1,000 · 최저", "✓ 최저(자동)"] + assert out["afterPick"] == '{"m1":{"pick":"물가정보"}}' + assert out["afterLowest"] == '{"m1":{"pick":"유통물가"}}' # 최저가여도 바꿈 + assert out["onPicked"] == "적용 단가 바꾸기" # 초록 테두리 칸 우클릭도 같은 메뉴 + assert out["other"] == [None, None, None] + assert ( + out["left"] == 4 and out["leftOther"] is None + ) # 왼쪽 누름 메뉴 그대로(출처 둘 · 구분선 · 최저) + assert out["cls"] == ["is-picked", None] + + +def test_배선_쉘_우클릭_CSS_초록_테두리(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert 'made.root.addEventListener("contextmenu"' in shell and "spec.cellMenu" in shell + assert "openMenuAt(event.clientX, event.clientY, items)" in shell + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + box = css.split("td.is-picked {", 1)[1].split("}", 1)[0] + assert "box-shadow: inset 0 0 0 2px var(--color-success)" in box and "background" not in box From a00bf2786a414151d50d5745aa1a8d9d70ac2dc0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:10:20 +0900 Subject: [PATCH 046/151] =?UTF-8?q?feat(b07):=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=ED=91=9C=20=EC=9C=84=20CAD=20=EC=B9=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=C2=B7=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=98=9B=20?= =?UTF-8?q?=EA=B8=80=20=EC=A4=84=20(260-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design.structure_note_cad 엔티티 = 범위 왼쪽 위를 표 왼쪽 · 윗변에 맞춤 · 표 층 · 배율 따름 - 표 · 테두리 · 시트 칸 높이 = 범위 높이 · 칸이 비면 아무것도 안 놓음 · 칸 없으면 옛 글 줄 - 시험 test_260_note_cad B07 몫 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Engine_Cad.py | 14 +- .../B07_DesignDetail_Engine_Cad_Sheet.py | 5 +- .../B07_DesignDetail_Engine_Cad_Table.py | 90 ++++++++++++ resources/tester/test_260_note_cad.py | 139 ++++++++++++++++++ 4 files changed, 237 insertions(+), 11 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 98a66b450..89ed9443c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -524,10 +524,9 @@ def build_cross_drawing( # 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다. from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( - _cross_note_entities, _cross_table_entities, - cross_note_height, - cross_note_lines, + cross_note_block_entities, + cross_note_block_height, cross_table_height, cross_table_width, ) @@ -550,7 +549,7 @@ def build_cross_drawing( if quantity_table is not None: stack = ( cross_table_height(design, table_scale) - + cross_note_height(cross_note_lines(design), table_scale) + + cross_note_block_height(design, table_scale) + 8.0 ) oy = table_bottom + stack + own_half_height * CROSS_MM @@ -580,12 +579,11 @@ def build_cross_drawing( table_bottom = oy - half_height if quantity_table is not None: table_top = oy - half_height - 8.0 - # 표 위 글(150-3) — 줄 수만큼 표를 내림 - note_lines = cross_note_lines(design) + # 표 위 글(150-3) · CAD 칸(260-3) — 그 높이만큼 표를 내림 entities.extend( - _cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale) + cross_note_block_entities(drawing_id, design, table_top, center_x, table_scale) ) - table_top -= cross_note_height(note_lines, table_scale) + table_top -= cross_note_block_height(design, table_scale) entities.extend( _cross_table_entities( drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 1dc94c77b..aecd97a15 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -34,8 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( - cross_note_height, - cross_note_lines, + cross_note_block_height, cross_table_height, cross_table_width, ) @@ -106,7 +105,7 @@ def section_block_size( top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: - note_h = cross_note_height(cross_note_lines(design_spec), table_scale) + note_h = cross_note_block_height(design_spec, table_scale) below += 8.0 + note_h + cross_table_height(design_spec, table_scale) return (width, top + below) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index 85a559e3e..fe102f202 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -401,6 +401,96 @@ def _cross_note_entities( ] +# 표 위 CAD 칸(260-3) — B06 [상세] 빈 CAD(`design.structure_note_cad`) 엔티티를 표 왼쪽 위에 맞춤. +# 칸이 있으면(엔티티가 비었어도) 옛 글 대신 이것 · 없으면 옛 글 줄 그대로. + + +def cross_note_cad(design: dict[str, Any] | None) -> list[dict[str, Any]] | None: + """설계의 표 위 CAD 엔티티 — 칸이 없으면 None(옛 글 줄로).""" + cad = design.get("structure_note_cad") if isinstance(design, dict) else None + if not isinstance(cad, dict): + return None + entities = cad.get("entities") + return [e for e in entities if isinstance(e, dict)] if isinstance(entities, list) else [] + + +def _text_width(label: str, font: float) -> float: + """글 폭 어림(mm) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배.""" + return sum(font if ord(char) >= 0x2E80 else 0.6 * font for char in label) + + +def note_cad_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None: + """CAD 칸 범위 (min_x, min_y, max_x, max_y) — 글은 기준점이 세로 가운데(CAD `textBaseline`)라 + 글 높이 · 폭을 맞춤 쪽으로 더함 · 그 밖은 `entities_bbox`.""" + from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox + + boxes = [] + for entity in entities: + shape = entity.get("shapeData") or {} + base = shape.get("basePoint") + if entity.get("type") == "Text" and isinstance(base, dict): + options = shape.get("options") or {} + font = float(options.get("fontSize") or 0.0) + width = _text_width(str(shape.get("label") or ""), font) + x, y = float(base["x"]), float(base["y"]) + left = {"center": x - width / 2.0, "right": x - width}.get(options.get("textAlign"), x) + boxes.append((left, y - font / 2.0, left + width, y + font / 2.0)) + elif (box := entities_bbox([entity])) is not None: + boxes.append(box) + if not boxes: + return None + return ( + min(b[0] for b in boxes), + min(b[1] for b in boxes), + max(b[2] for b in boxes), + max(b[3] for b in boxes), + ) + + +def _on_layer(entity: dict[str, Any], layer_id: str) -> dict[str, Any]: + entity["layerId"] = layer_id + for child in entity.get("children") or []: + _on_layer(child, layer_id) + return entity + + +def cross_note_block_height(design: dict[str, Any] | None, scale: float = 1.0) -> float: + """표 위 칸 높이(mm) — CAD 칸 = 범위 높이 × 배율 · 없으면 옛 글 줄 높이.""" + cad = cross_note_cad(design) + if cad is None: + return cross_note_height(cross_note_lines(design), scale) + box = note_cad_bbox(cad) + return (box[3] - box[1]) * scale if box else 0.0 + + +def cross_note_block_entities( + drawing_id: str, + design: dict[str, Any] | None, + top: float, + center_x: float = 0.0, + scale: float = 1.0, +) -> list[dict[str, Any]]: + """표 위 칸 엔티티 — CAD 칸 = 범위 왼쪽 위를 표 왼쪽 끝 · `top` 에 맞춰 옮김(× 배율 · 표 층 · + id `{도면}:notecad:{n}`) · 없으면 옛 글 줄(`_cross_note_entities`).""" + cad = cross_note_cad(design) + if cad is None: + return _cross_note_entities(drawing_id, cross_note_lines(design), top, center_x, scale) + box = note_cad_bbox(cad) + if box is None: + return [] + from B07_DesignDetail.B07_DesignDetail_Engine_Template import _transform_entity + + left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0 + dx, dy = left - box[0] * scale, top - box[3] * scale + return [ + _on_layer( + _transform_entity(entity, f"{drawing_id}:notecad:{index}", scale, dx, dy), + CROSS_TABLE_LAYER_ID, + ) + for index, entity in enumerate(cad) + ] + + def cross_table_width(scale: float = 1.0) -> float: """수량 산출표 가로 폭(mm).""" return _CROSS_TABLE_WIDTH * scale diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 774dbc8d8..2b408a5c4 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -155,3 +155,142 @@ def test_옛_글은_줄마다_Text_로_옮김(out): def test_측점_이름은_B07_꼴(out): assert out["station"] == ["NO.71+13", "NO.71", "NO.72"] + + +# ── 260-3 B07 표 위 ───────────────────────────────────────────────────── + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( # noqa: E402 + CROSS_TABLE_LAYER_ID, + FRAME_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( # noqa: E402 + cross_note_block_height, + cross_note_cad, + note_cad_bbox, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox # noqa: E402 + +_SOURCE = { + "chainage_m": 20.0, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.3 * x} + for x in range(-10, 11) + ], +} +_QTABLE = {"ground": 100.0, "planned": 99.5} + + +def _text(label: str, x: float, y: float, align: str = "left", font: float = 2.0) -> dict: + return { + "id": label, + "type": "Text", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": { + "label": label, + "basePoint": {"x": x, "y": y}, + "options": {"textAlign": align, "fontSize": font, "textColor": "#ff7f7f"}, + }, + } + + +# 미리 넣음과 같은 꼴 — 원(중심 6.5,-9 · 반지름 6.5) · 지시선 · 오른쪽 글(도면 좌표 아무 자리여도 됨) +_CIRCLE = { + "id": "c", + "type": "Circle", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"center": {"x": 106.5, "y": 41.0}, "radius": 6.5}, +} +_CAD = { + "structure_note_cad": { + "entities": [_CIRCLE, _text("NO.1", 115.0, 48.5), _text("관유출부보강", 115.0, 45.5)], + "layers": [{"id": "기본", "name": "기본"}], + } +} + + +def _draw(design: dict | None) -> dict: + return build_cross_drawing(_SOURCE, "cross_00020m", None, design, _QTABLE, "No.1") + + +def _table(drawing: dict) -> dict: + return next(e for e in drawing["entities"] if e["type"] == "Table") + + +def _frame_height(drawing: dict) -> float: + frame = [e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID] + _x0, y0, _x1, y1 = entities_bbox(frame) + return y1 - y0 + + +def test_CAD_칸_범위는_글_높이_폭까지(): + """글 기준점 = 세로 가운데 · 왼쪽 맞춤 폭 = 한글 글자 크기 · 원 = 중심 ± 반지름.""" + box = note_cad_bbox(_CAD["structure_note_cad"]["entities"]) + assert box == pytest.approx((100.0, 34.5, 115.0 + 6 * 2.0, 49.5)) + assert note_cad_bbox([_text("AB", 10.0, 0.0, "center")]) == pytest.approx((8.8, -1, 11.2, 1)) + assert note_cad_bbox([]) is None + assert cross_note_cad(None) is None and cross_note_cad({"structure_note": "x"}) is None + + +def test_CAD_칸은_표_왼쪽_위_맞춤_표는_범위_높이만큼_내려감(): + plain = _draw(None) + drawn = _draw(_CAD) + height = 49.5 - 34.5 + assert cross_note_block_height(_CAD) == pytest.approx(height) + plain_top = _table(plain)["shapeData"]["origin"]["y"] + drawn_top = _table(drawn)["shapeData"]["origin"]["y"] + assert plain_top - drawn_top == pytest.approx(height) + + placed = [e for e in drawn["entities"] if e.get("id") in _placed_ids()] + left = _table(drawn)["shapeData"]["origin"]["x"] + box = note_cad_bbox(placed) + # 좌상 맞춤 — 범위 왼쪽 = 표 왼쪽 · 범위 위 = 옛 표 윗변(글 없을 때 표 자리) + assert box[0] == pytest.approx(left) and box[3] == pytest.approx(plain_top) + assert box[1] == pytest.approx(drawn_top) + # 표 층으로 · 크기 그대로(배율 1) · 글 색 그대로 · id 는 도면마다 새로 + assert {e["layerId"] for e in placed} == {CROSS_TABLE_LAYER_ID} + circle = next(e for e in placed if e["type"] == "Circle") + assert circle["shapeData"]["radius"] == 6.5 + assert {e["lineColor"] for e in placed} == {"#ff7f7f"} + assert "c" not in {e["id"] for e in placed} + # 블록(테두리) · 시트 칸 크기도 같은 규칙 + assert _frame_height(drawn) - _frame_height(plain) == pytest.approx(height) + _w, plain_h = section_block_size(_SOURCE, None) + _w, cad_h = section_block_size(_SOURCE, None, design=_CAD) + assert cad_h - plain_h == pytest.approx(height) + _w, scaled_h = section_block_size(_SOURCE, None, design=_CAD, table_scale=2.0) + _w, plain2_h = section_block_size(_SOURCE, None, table_scale=2.0) + assert scaled_h - plain2_h == pytest.approx(2 * height) + + +def _placed_ids() -> set[str]: + """옮겨 놓은 CAD 칸 엔티티 id — 결정적 seed `{도면}:notecad:{n}`.""" + from uuid import uuid5 + + from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _ENTITY_NS + + return {str(uuid5(_ENTITY_NS, f"cross_00020m:notecad:{n}")) for n in range(3)} + + +def test_CAD_칸이_있으면_옛_글_대신(): + both = _draw({**_CAD, "structure_note": "옛 글"}) + labels = [e["shapeData"]["label"] for e in both["entities"] if e["type"] == "Text"] + assert "옛 글" not in labels and "NO.1" in labels + + +def test_CAD_칸이_비면_아무것도_안_놓음_옛_글도_안_씀(): + plain = _draw(None) + empty = _draw({"structure_note_cad": {"entities": []}, "structure_note": "옛 글"}) + assert _table(plain)["shapeData"] == _table(empty)["shapeData"] + assert not [e for e in empty["entities"] if e["type"] == "Text"] + + +def test_CAD_칸이_없으면_옛_글_줄_그대로(): + noted = _draw({"structure_note": "L형 측구\n집수정 보강"}) + labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"] + assert labels == ["L형 측구", "집수정 보강"] From 6dd6ced454e8bc769ac7b685693b8d3cd1f2bc9c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:10:35 +0900 Subject: [PATCH 047/151] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=8B=AB=ED=9E=8C=20=EC=98=81=EC=97=AD=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=20=C2=B7=20=EB=91=98=EB=A0=88=20?= =?UTF-8?q?=C2=B7=20=ED=91=9C=20=EC=B9=B8=20=E2=86=92=20=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=20=C2=B7=20=ED=91=9C=20=EA=B0=95=EC=A1=B0=20=EB=B0=94?= =?UTF-8?q?=EA=B9=A5=20=ED=95=9C=20=EC=A4=84=20(259-2=20=C2=B7=20259-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나눈 조각으로 면 찾기(열린 영역 제외) · 영역 안 누름 = 고름 · 채움 강조 · 면적 · 둘레 - 선 고름 경사비율 · 닫힌 선 둘레 · 띠 고름 값 글 - 표 칸 누름 = 저장된 조각 그래프 고름 · 띠 강조 + 값 글 - 고른 칸 묶음 바깥 한 줄(겹선 없음) · 줄 물들임 없앰 · 합친 묶음 이름 칸 키 맞춤 - 259-2 · 259-3 이 같은 파일(상세 표 배선)이라 한 커밋 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Cross_Detail.ts | 69 ++++++++-- .../B06_Section_UI_Cross_Detail_Geom.ts | 85 ++++++++++++- .../B06_Section_UI_Cross_Detail_Pick.ts | 46 +++++++ .../B06_Section_UI_Cross_Detail_Select.ts | 120 ++++++++++++++++-- .../B06_Section_UI_Cross_Detail_Table.ts | 58 ++++++++- .../B06_Section_UI_Style_Cross_Detail.css | 37 +++++- resources/tester/helper_259_detail_region.cjs | 89 +++++++++++++ resources/tester/test_150_detail_pick.py | 9 +- resources/tester/test_164_detail_zoom.py | 16 ++- resources/tester/test_209_1_detail_select.py | 4 +- resources/tester/test_259_detail_region.py | 90 ++++++++++++- 11 files changed, 575 insertions(+), 48 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..7ded9b978 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -24,7 +24,12 @@ import type { SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; -import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick"; +import { + DETAIL_TARGETS, + highlightDetail, + outlinePicked, + presentKeys, +} from "./B06_Section_UI_Cross_Detail_Pick"; import { attachSvgZoomPan, type ZoomPanHandle, @@ -132,18 +137,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT if (repick.has(key)) td.dataset.repick = ""; return mark(td, { cell: key, row, col }, isAdded(key)); }; - // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) + // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3) const nameCell = ( label: string | null, span: { col?: number; row?: number } | undefined, key: string | null, row: string, slot: number, + keys?: Array, ): HTMLElement => { const added = row.startsWith("r"); - const data = { row, label: added ? `${row}:${slot}` : undefined }; + const data = { + row, + label: added ? `${row}:${slot}` : undefined, + keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined, + }; return mark(cell("th", label ?? "", span, key), data, added); }; + const groupKeys = (slots: typeof layout.left, index: number) => + slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]); const table = document.createElement("table"); table.className = "b06-detail__qtable"; const titleRow = table.insertRow(); @@ -158,13 +170,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT const row = table.insertRow(); // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4) if (groupL !== null && subL !== null) - row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)); + row.append( + nameCell( + groupL, + { row: groupSpan(layout.left, index) }, + keyL, + id, + 0, + groupKeys(layout.left, index), + ), + ); else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0)); if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0)); row.append(valueCell(keyL, id)); const [groupM, subM, keyM] = layout.middle[index]; if (groupM !== null && subM !== null) - row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)); + row.append( + nameCell( + groupM, + { row: groupSpan(layout.middle, index) }, + keyM, + id, + 1, + groupKeys(layout.middle, index), + ), + ); else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1)); if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1)); row.append(valueCell(keyM, id)); @@ -289,24 +319,35 @@ export function openCrossDetail(section: CrossSection): void { tableBox.className = "b06-detail__table"; body.append(cad, note, tableBox); - // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) + // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) · + // 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3) let picked: string | null = null; const showPicked = (): void => { const svg = cad.querySelector("svg"); const present = svg ? presentKeys(svg, state.section.design) : new Set(); - if (picked && !present.has(picked)) picked = null; - tableBox.querySelectorAll("[data-key]").forEach((el) => { - el.classList.toggle("is-picked", el.dataset.key === picked); - el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? "")); + const graph = picked ? tablePick.graph(picked) : null; + if (picked && !graph && !present.has(picked)) picked = null; + tableBox.querySelectorAll("[data-key], [data-cell]").forEach((el) => { + const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" "); + el.classList.toggle("is-picked", picked !== null && keys.includes(picked)); + if (el.dataset.key) + el.toggleAttribute( + "data-empty", + !present.has(el.dataset.key) && !tablePick.graph(el.dataset.key), + ); }); - if (svg) highlightDetail(svg, state.section.design, picked); + outlinePicked(tableBox); + const band = graph && "band" in graph ? graph.band : graph ? null : picked; + if (svg) highlightDetail(svg, state.section.design, band); }; tableBox.addEventListener("click", (event) => { - const el = (event.target as HTMLElement).closest("[data-key]"); - const key = el?.dataset.key; - if (!key || el.hasAttribute("data-empty")) return; + const el = (event.target as HTMLElement).closest("[data-key], [data-cell]"); + const key = el?.dataset.key ?? el?.dataset.cell; + if (!el || !key) return; + if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return; picked = picked === key ? null : key; showPicked(); + tablePick.showCell(picked); }); // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index 3f15d056d..8e7839a35 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -5,7 +5,7 @@ * 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py` * (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) · * 거울 시험 `resources/tester/test_209_1_detail_select.py`. - * 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. + * 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. * ========================================================================== */ import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; @@ -227,6 +227,89 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { return out; } +/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */ + +/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */ +export interface Face { + edges: number[]; + ring: Pt[]; + area: number; +} + +/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */ +function leaving(points: Pt[]): number { + const [x0, y0] = points[0]; + const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1]; + return Math.atan2(next[1] - y0, next[0] - x0); +} + +/** + * 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은 + * 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 · + * 넓이가 양(반시계)인 것만 = 바깥 면 뺌. + * ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함. + */ +export function findFaces(pieces: SplitPiece[]): Face[] { + const nodes: Pt[] = []; + const nodeOf = (p: Pt): number => { + const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M); + return found >= 0 ? found : nodes.push(p) - 1; + }; + const ends = pieces.map((piece) => [ + nodeOf(piece.points[0]), + nodeOf(piece.points[piece.points.length - 1]), + ]); + const alive = ends.map(([a, b]) => a !== b); + for (let changed = true; changed;) { + const degree = new Array(nodes.length).fill(0); + ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++)); + changed = false; + ends.forEach(([a, b], i) => { + if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true]; + }); + } + // 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞) + const path = (h: number): Pt[] => { + const points = pieces[h >> 1].points; + return h & 1 ? [...points].reverse() : points; + }; + const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1]; + const out = nodes.map(() => [] as Array<[number, number]>); + ends.forEach(([a, b], i) => { + if (!alive[i]) return; + out[a].push([leaving(path(2 * i)), 2 * i]); + out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]); + }); + out.forEach((list) => list.sort((p, q) => p[0] - q[0])); + const next = (h: number): number => { + const list = out[head(h)]; + const at = list.findIndex(([, e]) => e === (h ^ 1)); + return list[(at - 1 + list.length) % list.length][1]; + }; + const faces: Face[] = []; + const seen = new Set(); + ends.forEach((_, i) => { + if (!alive[i]) return; + for (const start of [2 * i, 2 * i + 1]) { + if (seen.has(start)) continue; + const cycle: number[] = []; + for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) { + seen.add(h); + cycle.push(h); + } + const ring = cycle.flatMap((h) => path(h).slice(1)); + let twice = 0; + ring.forEach((p, k) => { + const q = ring[(k + 1) % ring.length]; + twice += p[0] * q[1] - q[0] * p[1]; + }); + if (twice / 2 > 1e-9) + faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 }); + } + }); + return faces; +} + /** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */ export function pieceSource(piece: SplitPiece): CrossPickPiece { const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4]; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts index 01f3415c9..f76ffb8cf 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -6,6 +6,7 @@ * (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려 * `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리. * 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖. + * 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3). * ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름). * ========================================================================== */ @@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set< Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0), ); } + +/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */ + +/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */ +export function unionOutline(rects: number[][]): string { + const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b); + const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b); + const covered = (i: number, j: number): boolean => + i >= 0 && + j >= 0 && + i + 1 < xs.length && + j + 1 < ys.length && + rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]); + const parts: string[] = []; + for (let i = 0; i + 1 < xs.length; i++) + for (let j = 0; j + 1 < ys.length; j++) { + if (!covered(i, j)) continue; + const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]]; + if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`); + if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`); + if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`); + if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`); + } + return parts.join(""); +} + +/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */ +export function outlinePicked(box: HTMLElement): void { + box.querySelector(".b06-detail__pick-outline")?.remove(); + const cells = [...box.querySelectorAll(".is-picked")]; + if (!cells.length) return; + const base = box.getBoundingClientRect(); + const rects = cells.map((cell) => { + const r = cell.getBoundingClientRect(); + return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map( + (v) => Math.round(v), + ); + }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "b06-detail__pick-outline"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", unionOutline(rects)); + svg.append(path); + box.append(svg); +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index b3d8442c4..98f4d5eba 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -3,7 +3,10 @@ * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): - * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). + * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). + * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) + * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. + * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. * ========================================================================== */ @@ -17,16 +20,21 @@ import { pieceLabel, } from "./B06_Section_UI_Cross_Design_Pick"; import { + findFaces, + LINE_TOLERANCE_M, pathLength, pieceSource, ringArea, splitAtCrossings, + type Face, type Pt, type SplitPiece, } from "./B06_Section_UI_Cross_Detail_Geom"; const SVG_NS = "http://www.w3.org/2000/svg"; export const PIECE_HIT_CLASS = "b06-detail__piece"; +/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */ +export const FACE_HIT_CLASS = "b06-detail__face"; /** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { @@ -124,16 +132,45 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie to: { offset: to[0], elevation: to[1] }, structure: piece.line === "structure", }); + const horizontal = Math.abs(to[0] - from[0]); + const height = Math.abs(to[1] - from[1]); + const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직"; return ( - `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` + - `수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m` + `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` + + `수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m` ); } const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); const area = ringArea(pieces.map((piece) => piece.points)); return ( `${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` + - (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`) + (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`) + ); +} + +/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */ +export function regionLabel(faces: Face[], pieces: SplitPiece[]): string { + const area = faces.reduce((sum, face) => sum + face.area, 0); + const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); + const count = faces.length > 1 ? `${faces.length}개 · ` : ""; + return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`; +} + +/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */ +export function regionEdges(faces: Face[]): number[] { + const count = new Map(); + for (const face of faces) + for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1); + return [...count].filter(([, n]) => n % 2).map(([edge]) => edge); +} + +/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */ +export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean { + if (piece.line !== stored.line) return false; + const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M; + const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]]; + return ( + (near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start)) ); } @@ -156,6 +193,10 @@ export interface DetailSelect { setMode: (kind: "area" | "length" | null) => void; source: (kind: "area" | "length") => ReturnType; clear: () => void; + /** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */ + choose: (stored: CrossPickPiece[] | null) => void; + /** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */ + note: (text: string | null) => void; detach: () => void; } @@ -171,11 +212,18 @@ export function attachDetailSelect(input: { picked: (additive: boolean) => void; /** [칸에 넣기] 단추. */ commit: () => void; + /** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */ + band?: (event: MouseEvent) => boolean; }): DetailSelect { const { cad } = input; let pieces: SplitPiece[] = []; let hits: SVGPolylineElement[] = []; + let faces: Face[] = []; + let faceEls: SVGPolygonElement[] = []; let selected: number[] = []; + /** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */ + let chosenFaces: number[] = []; + let noteText: string | null = null; let mode: "area" | "length" | null = null; const label = document.createElement("span"); @@ -193,8 +241,21 @@ export function attachDetailSelect(input: { const show = (): void => { const chosen = selected.map((i) => pieces[i]); hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i))); - label.textContent = selectionLabel(input.design(), chosen); - label.hidden = !chosen.length; + const same = (edges: number[]) => + edges.length === selected.length && edges.every((edge) => selected.includes(edge)); + const filled = chosenFaces.length + ? chosenFaces + : faces.flatMap((face, i) => (same(face.edges) ? [i] : [])); + faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i))); + label.textContent = + noteText ?? + (filled.length + ? regionLabel( + filled.map((i) => faces[i]), + chosen, + ) + : selectionLabel(input.design(), chosen)); + label.hidden = !label.textContent; put.hidden = !mode || !selectionSource(mode, chosen); // [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김 const bar = cad.querySelector(".b06-cross-card__zoom"); @@ -202,16 +263,29 @@ export function attachDetailSelect(input: { }; const onClick = (event: MouseEvent): void => { - const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`); - const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area"; + const target = event.target as Element; + const hit = target.closest?.(`.${PIECE_HIT_CLASS}`); + const shift = event.shiftKey || event.ctrlKey || event.metaKey; + const additive = shift || mode === "area"; + noteText = null; if (!hit) { - if (!additive && !mode && selected.length) { - selected = []; + const face = target.closest?.(`.${FACE_HIT_CLASS}`); + if (face && !(mode === "area" && input.band?.(event))) { + // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) + event.stopPropagation(); + const index = faceEls.indexOf(face as SVGPolygonElement); + chosenFaces = toggleSelection(chosenFaces, index, shift); + selected = regionEdges(chosenFaces.map((i) => faces[i])); show(); + input.picked(true); + return; } + if (!additive && !mode) [selected, chosenFaces] = [[], []]; + show(); return; } event.stopPropagation(); + chosenFaces = []; selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); show(); input.picked(additive); @@ -226,7 +300,19 @@ export function attachDetailSelect(input: { const layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); + chosenFaces = []; + faces = findFaces(pieces); const toPx = axes ? mToPx(axes) : null; + // 영역 판정 면 먼저(밑) → 조각 판정 선(위) + faceEls = toPx + ? faces.map((face) => { + const el = document.createElementNS(SVG_NS, "polygon"); + el.setAttribute("points", face.ring.map(toPx).join(" ")); + el.setAttribute("class", FACE_HIT_CLASS); + layer?.append(el); + return el; + }) + : []; hits = toPx ? pieces.map((piece) => { const hit = document.createElementNS(SVG_NS, "polyline"); @@ -248,7 +334,19 @@ export function attachDetailSelect(input: { selected.map((i) => pieces[i]), ), clear() { - selected = []; + [selected, chosenFaces] = [[], []]; + show(); + }, + choose(stored) { + chosenFaces = []; + noteText = null; + selected = stored + ? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : [])) + : []; + show(); + }, + note(text) { + noteText = text; show(); }, detach() { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 24bf4203d..65cc99823 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -9,6 +9,8 @@ * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) — * 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록. * 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기]. + * 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞. + * 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`). * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -19,7 +21,7 @@ import type { CrossDetailTable, CrossSection, } from "./B06_Section_Api_Fetch"; -import type { SectionSample } from "./B06_Section_Api_Types"; +import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; @@ -173,6 +175,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null { return band ? { src: "area", key: band[1] } : null; } +/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */ +export function cellGraph( + table: CrossDetailTable, + key: string, +): { pieces: CrossPickPiece[] } | { band: string } | null { + const source = table.cells[key]; + if (source && "pieces" in source) return { pieces: source.pieces }; + if (source && source.src === "area" && "key" in source) return { band: source.key }; + return null; +} + +const BAND_NAME: Readonly> = { + cut_soil: "깍기(토사)", + cut_rock: "깍기(암석)", + ditch_soil: "측구(토사)", + ditch_rock: "측구(암석)", + embankment: "쌓기", + spoil_fill: "사토장", +}; + +/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */ +export function bandLabel(key: string, design: CrossDesign | undefined): string | null { + const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null; + return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`; +} + /* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ const AREA_DESIGN: Readonly> = { @@ -280,7 +308,7 @@ export interface DetailTableHost { let menu: ReturnType | null = null; const PICK_HINT = { - area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소", + area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소", length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소", } as const; @@ -289,6 +317,10 @@ const PICK_HINT = { export function attachDetailTable(host: DetailTableHost): { detach: () => void; redraw: () => void; + /** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */ + graph: (key: string) => ReturnType; + /** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */ + showCell: (key: string | null) => void; } { menu ??= createMapContextMenu("b06-detail"); const ctxMenu = menu; @@ -323,6 +355,7 @@ export function attachDetailTable(host: DetailTableHost): { const shown = localCellValue(source, section.design, section.samples); const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); + if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2) edit((table) => setCell(table, key, source)); }; const commitSelection = (): void => { @@ -354,14 +387,17 @@ export function attachDetailTable(host: DetailTableHost): { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, + band: (event) => !!bandAt(event), }); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음 + const bandAt = (event: MouseEvent): Element | undefined => + document + .elementsFromPoint(event.clientX, event.clientY) + .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음 const onPick = (event: MouseEvent): void => { if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) return; - const hit = document - .elementsFromPoint(event.clientX, event.clientY) - .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); + const hit = bandAt(event); const source = hit ? areaSourceOf(hit) : null; if (!source) return; event.stopPropagation(); @@ -426,5 +462,15 @@ export function attachDetailTable(host: DetailTableHost): { tableBox.removeEventListener("keydown", onEnter); }, redraw: () => select.redraw(), + graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key), + showCell: (key) => { + const target = key + ? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key) + : null; + select.choose(target && "pieces" in target ? target.pieces : null); + // 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠 + const band = !target ? key : "band" in target ? target.band : null; + select.note(band ? bandLabel(band, host.section().design) : null); + }, }; } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 2f14f0637..be3ea6726 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -28,6 +28,8 @@ .b06-detail__table { flex: none; + /* 고른 칸 바깥 줄(259-3) 자리 기준 */ + position: relative; } /* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */ @@ -81,7 +83,7 @@ color: var(--color-text); } -/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */ +/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 붉게 · 테두리는 묶음 바깥 한 줄(259-3) */ .b06-detail__qtable [data-key] { cursor: pointer; } @@ -92,12 +94,23 @@ .b06-detail__qtable .is-picked { background: color-mix(in srgb, var(--color-danger) 18%, transparent); - box-shadow: inset 0 0 0 1px var(--color-danger); color: var(--color-danger); } -.b06-detail__qtable tr:has(.is-picked) { - background: color-mix(in srgb, var(--color-danger) 5%, transparent); +.b06-detail__pick-outline { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.b06-detail__pick-outline path { + fill: none; + stroke: var(--color-danger); + stroke-width: 2; + stroke-linecap: square; } /* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ @@ -188,6 +201,22 @@ stroke-width: 6; } +/* 닫힌 영역 판정 면(259-2) — 투명 채움으로 누름 · 고른 영역 = 붉은 채움 */ +.b06-detail__face { + fill: transparent; + stroke: none; + pointer-events: fill; + cursor: pointer; +} + +.b06-detail__face:hover { + fill: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.b06-detail__face.is-selected { + fill: color-mix(in srgb, var(--color-danger) 22%, transparent); +} + /* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */ .b06-detail__commit { width: auto; diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs index 61139b6dd..a17c883c6 100644 --- a/resources/tester/helper_259_detail_region.cjs +++ b/resources/tester/helper_259_detail_region.cjs @@ -130,4 +130,93 @@ out.rockSource = S.selectionSource("length", [rockPieces[1]]); out.rockLocal = T.localCellValue(out.rockSource, design, samples); out.rockLength = G.pathLength(rockPieces[1].points); +/* ③ 닫힌 영역(259-2) — 면 찾기 · 열린 영역 제외 · 영역 고름 값 · 붙은 두 영역 = 맞닿은 변 뺌 */ +const faces = G.findFaces(pieces); +out.faces = faces.map((face) => ({ + area: face.area, + lines: face.edges.map((i) => pieces[i].line).sort(), +})); +const one = faces.map((face) => + S.regionLabel( + [face], + face.edges.map((i) => pieces[i]), + ), +); +out.faceLabels = one; +const union = S.regionEdges(faces); +const unionPieces = union.map((i) => pieces[i]); +out.union = { + lines: unionPieces.map((p) => p.line).sort(), + label: S.regionLabel(faces, unionPieces), + selectLabel: S.selectionLabel(design, unionPieces), + source: S.selectionSource("area", unionPieces), +}; +out.union.local = T.localCellValue(out.union.source, design, samples); +// 열린 원지반만 = 면 없음 +out.openFaces = G.findFaces(pieces.filter((p) => p.line === "ground")).length; +// 정사각형 하나 + 매달린 꼬리 — 꼬리 지우고 면 하나(넓이 1) +out.square = G.findFaces([ + { + line: "ground", + points: [ + [0, 0], + [1, 0], + ], + }, + { + line: "design", + points: [ + [1, 0], + [1, 1], + [0, 1], + ], + }, + { + line: "rock", + points: [ + [0, 1], + [0, 0], + ], + }, + { + line: "spoil", + points: [ + [1, 1], + [3, 3], + ], + }, +]).map((face) => ({ area: face.area, edges: face.edges.sort() })); + +/* ④ 표 칸 → 그래프(259-2) — 저장된 조각 짝 · 칸 그래프 짝 · 띠 값 글 */ +const stored = G.pieceSource(rockPieces[1]); +out.same = { + forward: S.samePiece(rockPieces[1], stored), + reverse: S.samePiece(rockPieces[1], { ...stored, start: stored.end, end: stored.start }), + otherLine: S.samePiece(rockPieces[1], { ...stored, line: "ground" }), + otherPiece: S.samePiece(rockPieces[0], stored), +}; +const table = { + cells: { + fence: out.rockSource, + cut_soil: { src: "area", key: "cut_rock" }, + "r:r1:0": { src: "text", text: "글" }, + }, + rows: [], + columns: [], +}; +out.cellGraph = ["fence", "cut_soil", "r:r1:0", "embankment"].map((key) => T.cellGraph(table, key)); +out.bandLabel = [ + T.bandLabel("cut_soil", { cut_soil_area_m2: 1.234 }), + T.bandLabel("cut_soil", {}), + T.bandLabel("ground", { cut_soil_area_m2: 1 }), +]; + +/* ⑤ 표 강조 바깥 한 줄(259-3) — 합친 묶음 이름 칸(두 줄) + 둘째 줄 이름 · 값 = ㄴ 모양 */ +const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Pick.ts")); +out.outline = P.unionOutline([ + [0, 0, 10, 20], + [10, 10, 20, 20], + [20, 10, 30, 20], +]); + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index f9df29850..717cf926c 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -80,8 +80,9 @@ def test_상세_표_칸_키_누름_배선(): ) as f: src = f.read() assert "element.dataset.key = key" in src - assert "highlightDetail(svg, state.section.design, picked)" in src - assert 'closest("[data-key]")' in src + # 259-2 — 저장 띠 출처면 그 띠 · 저장 조각이면 강조 없음(그래프 고름) · 값 칸(data-cell)도 누름 + assert "highlightDetail(svg, state.section.design, band)" in src + assert 'closest("[data-key], [data-cell]")' in src def test_상세_큰_창_공용_막_그림_글_표_순서(): @@ -93,6 +94,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서(): css = f.read().replace(chr(13), "") assert "wide: true" in src and "backdropClass" not in src assert "body.append(cad, note, tableBox)" in src - assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css + # 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none + backdrop = css.split(".b06-detail__pick-outline {")[0] + assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득 diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index e85518359..1b14444f5 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -105,10 +105,12 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): ".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);", ".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);", ".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%", - ".b06-detail__qtable tr:has(.is-picked) {", ".b06-detail__qtable [data-key][data-empty] {\n cursor: default;", ): assert selector in css, selector + # 259-3 — 줄 물들임 · 칸마다 테두리 없앰(묶음 바깥 한 줄) + assert "tr:has(.is-picked)" not in css + assert "inset 0 0 0 1px var(--color-danger)" not in css assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1] assert ( ".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%" @@ -118,11 +120,13 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): def test_빈칸_누름_없음_이름_칸_첫_키(): src = _read("B06_Section_UI_Cross_Detail.ts") - assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src - assert 'if (!key || el.hasAttribute("data-empty")) return;' in src - # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 - assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src - assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src + assert "!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key)" in src + assert '(!el.dataset.key || el.hasAttribute("data-empty"))) return;' in src + # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 · + # 259-3 덮는 줄 키 모두(data-keys) + flat = " ".join(src.split()) + assert "nameCell( groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0," in flat + assert "nameCell( groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1," in flat def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만(): diff --git a/resources/tester/test_209_1_detail_select.py b/resources/tester/test_209_1_detail_select.py index b695354c9..9ef743826 100644 --- a/resources/tester/test_209_1_detail_select.py +++ b/resources/tester/test_209_1_detail_select.py @@ -117,11 +117,11 @@ def test_원지반_포함_닫힌_영역_넓이(out): def test_정보창_값(out): label = out["label"] length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X - assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡" + assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡ · 둘레 {length:.2f}m" assert label["open"].endswith("면적 닫히지 않음") assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴 assert label["ground"] == ( - f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" + f"원지반선 길이 {2 * CROSS_X:.2f}m · 경사비율 0.0%\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" ) diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py index 4a0c3b66e..c5c4351bc 100644 --- a/resources/tester/test_259_detail_region.py +++ b/resources/tester/test_259_detail_region.py @@ -6,6 +6,9 @@ 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · 암 프리셋 아니면 다시 고름. + - 259-2 닫힌 영역 — 면 찾기(열린 영역 · 매달린 꼬리 제외) · 영역 고름 값(면적 · 둘레) · 붙은 영역 = + 맞닿은 변 뺌 · 서버 거울 · 표 칸 → 저장된 조각 · 띠 짝 · 띠 값 글. + - 259-3 표 강조 — 고른 칸 묶음 바깥 한 줄(칸 사이 겹선 없음). ./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q """ @@ -13,6 +16,7 @@ import json import os import subprocess +from math import hypot from pathlib import Path import pytest @@ -45,6 +49,7 @@ AXES = { "exaggeration": 2, } ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset +GROUND_X = 2 + 4 / 3 # 경사가 원지반 100 m 와 만나는 offset pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" @@ -98,7 +103,9 @@ def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): def test_암_경계선_고름_값_저장_꼴(out): length = 2 * ROCK_X assert out["rockLength"] == pytest.approx(length, abs=1e-3) - assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m" + assert out["rockLabel"] == ( + f"암 경계선 길이 {length:.2f}m · 경사비율 0.0%\n수평 {length:.2f}m · 높이 0.00m" + ) source = out["rockSource"] assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) @@ -113,3 +120,84 @@ def test_암_경계선_서버_거울(out): soil = {**DESIGN, "geometry_preset": "soil"} assert pick_lines(soil, SAMPLES)["rock"] == [] assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True) + + +# ── 259-2 닫힌 영역 · 영역 고름 · 면적 ──────────────────────────────────── + +SLOPE_1M = hypot(4 / 6, 1.0) # 경사 조각 높이 1 m 길이 +FACE_TOP = (2 * GROUND_X + 2 * ROCK_X) / 2 # 원지반 100 ~ 암 99 사이 사다리꼴 +FACE_LOW = (2 * ROCK_X + 4) / 2 # 암 99 ~ 바닥 98 사이 사다리꼴 + + +def test_면_찾기_열린_영역_제외(out): + faces = sorted(out["faces"], key=lambda f: -f["area"]) + assert [f["area"] for f in faces] == pytest.approx([FACE_TOP, FACE_LOW], abs=1e-6) + assert faces[0]["lines"] == ["design", "design", "ground", "rock"] + assert faces[1]["lines"] == ["design", "design", "design", "rock"] + assert out["openFaces"] == 0 # 원지반만 = 열림 + assert out["square"] == [{"area": pytest.approx(1.0), "edges": [0, 1, 2]}] # 꼬리 지움 + + +def test_영역_고름_면적_둘레(out): + top = 2 * GROUND_X + 2 * ROCK_X + 2 * SLOPE_1M + low = 2 * ROCK_X + 4 + 2 * SLOPE_1M + assert sorted(out["faceLabels"]) == sorted( + [ + f"영역 면적 {FACE_TOP:.2f}㎡\n둘레 {top:.2f}m", + f"영역 면적 {FACE_LOW:.2f}㎡\n둘레 {low:.2f}m", + ] + ) + + +def test_붙은_두_영역_맞닿은_변_뺌(out): + union = out["union"] + assert union["lines"] == ["design"] * 5 + ["ground"] # 암 경계선 빠짐 + area = FACE_TOP + FACE_LOW + around = 2 * GROUND_X + 4 + 4 * SLOPE_1M + assert union["label"] == f"영역 2개 · 면적 {area:.2f}㎡\n둘레 {around:.2f}m" + assert ( + union["selectLabel"] + == f"6개 · 길이 합 {around:.2f}m\n면적 {area:.2f}㎡ · 둘레 {around:.2f}m" + ) + assert union["local"] == pytest.approx(area, abs=1e-3) + number, _text, again = cell_value(union["source"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(union["local"], abs=1e-6) + + +# ── 259-2 표 칸 → 그래프 ───────────────────────────────────────────────── + + +def test_표_칸_저장된_조각을_그래프에서_고름(out): + assert out["same"] == { + "forward": True, + "reverse": True, + "otherLine": False, + "otherPiece": False, + } + pieces, band, text, none = out["cellGraph"] + assert pieces == {"pieces": out["rockSource"]["pieces"]} + assert band == {"band": "cut_rock"} + assert text is None and none is None + + +def test_띠_값_글(out): + assert out["bandLabel"] == ["깍기(토사) 면적 1.23㎡", None, None] + + +# ── 259-3 표 강조 바깥 한 줄 ────────────────────────────────────────────── + + +def test_고른_칸_묶음_바깥_한_줄(out): + parts = {"M" + p for p in out["outline"].split("M") if p} + assert parts == { + "M0 0H10", + "M0 0V10", + "M10 0V10", + "M0 20H10", + "M0 10V20", + "M10 10H20", + "M10 20H20", + "M20 10H30", + "M20 20H30", + "M30 10V20", + } # 칸 사이(10 · 20 세로) 겹선 없음 From cb2db4ff4979f34b121156d48fed86073d7cd2f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:10:38 +0900 Subject: [PATCH 048/151] =?UTF-8?q?feat(b09):=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B8=B8=20?= =?UTF-8?q?(253-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/projects/{id}/estimation/logic-preview — 로직 한 산근을 프로젝트 단가로 풀어 계산 절차 글 · 단가(저장 안 함) - 「운반 단가」 모달 — 단가산출 이름 · [변경] · [다른 종류] · 옵션 값(로직 입력 + 사이클 입력 · 표 고르기 CC000054~058) · 계산 절차(사이클 식 + 미리보기) - 여는 곳 = B08 거리이정표 표 우클릭 [단가연결] · B09 설계내역서 운반공 줄 단가연결 · 운반 항목에 거리(dist) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_UI_Milestone.ts | 26 +- B09_Estimation/B09_Estimation_Bill_Items.py | 6 +- B09_Estimation/B09_Estimation_Router.py | 42 +- .../B09_Estimation_UI_Haul_Modal.ts | 380 ++++++++++++++++++ .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 8 +- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 33 +- resources/tester/test_253_3_haul_preview.py | 62 +++ 7 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Haul_Modal.ts create mode 100644 resources/tester/test_253_3_haul_preview.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 148f27c34..310d1bef4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -10,7 +10,8 @@ * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · - * [단가연결 풀기] — 창 = B09 설계내역서와 같은 `openPriceLink` · 경로 `ref` · B09 운반공 줄이 받음 + * [단가연결 풀기] — 창 = 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · + * B09 운반공 줄이 받음 * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; @@ -18,6 +19,7 @@ import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; +import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; @@ -100,7 +102,7 @@ export function addRoute(projectId: string, init: Partial = {}): /** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); - let keys: Array<{ route: string; index: number }> = []; + let keys: Array<{ route: string; index: number; L1: number; L2: number }> = []; const routeAt = (i: number) => liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route); const makeTable = (columns: DataColumn[]) => { @@ -151,8 +153,19 @@ function tableView(projectId: string): MilestoneView { // 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음 r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" }; }); + const at = keys[Number(tr?.dataset.i)]; pickAtCell([ - ["단가연결", () => void openPriceLink({ apply: link })], + [ + "단가연결", + () => + openHaulModal({ + ref: (route.ref as Row | null) ?? null, + dist: at && { L1: at.L1, L2: at.L2 }, + name: route.rows[at?.index ?? 0]?.item || route.name || "운반", + apply: link, + other: () => void openPriceLink({ apply: link }), + }), + ], ...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []), ]); }); @@ -180,7 +193,12 @@ function tableView(projectId: string): MilestoneView { table.root.replaceWith(fresh.root); table = fresh; } - keys = rows.map(({ route, index }) => ({ route, index })); + keys = rows.map(({ route, index, paved, unpaved }) => ({ + route, + index, + L1: paved, + L2: unpaved, + })); none.hidden = rows.length > 0; table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); diagram.refresh(); diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index d8a80c3df..72ad89487 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -112,7 +112,9 @@ def haul_items( ) -> list[dict[str, Any]]: """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + 거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) · - 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김).""" + 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) · + `dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None).""" + dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []} out = [] for g in haul_groups(materials): qty = g["qty"] @@ -128,6 +130,7 @@ def haul_items( "qty": qty, "note": "B08 총괄자재" if qty is not None else "", "ref": ref, + "dist": dists.get(bill_key(g["key"])), "why": WHY_LOGIC if logic else WHY_HAUL, } ) @@ -146,6 +149,7 @@ def haul_items( "qty": None, "note": "B08 거리이정표", "ref": ref, + "dist": dists.get(r["key"]), "why": WHY_LOGIC if logic else WHY_HAUL, } ) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d23353b28..7b0e48a41 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,6 +6,9 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} + 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · + 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) POST /api/projects/{id}/estimation/library {kind, layer, owner, key} 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · @@ -25,7 +28,7 @@ from typing import Any from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from B08_Quantity.B08_Quantity_Haul import haul_rows from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone @@ -206,6 +209,43 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: return await payload(project_id, root) +class PreviewBody(BaseModel): + code: str = Field(min_length=1, max_length=40) + given: dict[str, Any] = Field(default_factory=dict) + rate: float | None = None + picks: dict[str, Any] | None = None + + +def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]: + """로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름.""" + src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate, + "picks": body.picks} # fmt: skip + hands = [("preview", "미리보기", src)] + keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}}) + user = store.read_user(root) + base = compute_base(prices.build_inputs(store.read_book(root), keys), user) + block = sangeun.Solver(base).add(sangeun.hand_whole(hands), src, None) + text = [ + *block["head"], + *(t for ln in block["lines"] for t in ln.get("text") or []), + block["foot"], + ] + return { + "name": block["name"], + "lines": [t for t in text if t], + "unit": block["unit"], + "price": block["sum"], + "parts": {p: block[p] for p in sangeun.PARTS}, + "why": block["why"], + } + + +@router.post("/{project_id}/estimation/logic-preview") +async def preview_logic(project_id: UUID, body: PreviewBody) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(logic_preview, root, body) + + class LibraryBody(BaseModel): kind: str layer: str diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts new file mode 100644 index 000000000..e5291bed2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -0,0 +1,380 @@ +/* ============================================================================= + * B09_Estimation_UI_Haul_Modal.ts + * 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. + * 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) · + * 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) · + * 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄 + * (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). + * 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · + * 빈칸으로 지우면 다시 거리에서. + * 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). + * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결. + * ========================================================================== */ + +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + CYCLE_DEFAULTS, + CYCLE_FILL, + cycleInputs, + cycleTime, + fillGiven, + type Dist, +} from "../B08_Quantity/B08_Quantity_Haul_Cycle"; +import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note"; +import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; +import type { Row } from "./B09_Estimation_Calc_Base"; + +type Apply = (ref: Row | null, from?: Row) => void; +export interface HaulModalOpts { + /** 지금 연결(없으면 null) */ + ref: Row | null; + /** 그 행 거리 — L1 포장 계 · L2 비포장(km) */ + dist: Dist; + /** 줄 이름(제목) */ + name: string; + apply: Apply; + /** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */ + other?: () => void; +} + +/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */ +const CYCLE_FIELDS: Array<[string, string, string][]> = [ + [ + ["L1", "포장 운반거리 L1", "km"], + ["L2", "비포장 운반거리 L2", "km"], + ], + [ + ["V1", "포장 적재 V1", "km/hr"], + ["V2", "포장 공차 V2", "km/hr"], + ["V3", "비포장 적재 V3", "km/hr"], + ["V4", "비포장 공차 V4", "km/hr"], + ], + [ + ["t1", "적재 t1", "분"], + ["T", "적재량 T", "ton"], + ["rt", "단위중량 rt", "t/㎥"], + ["f", "체적환산 f", ""], + ["qs", "버킷용량 qs", "㎥"], + ["k", "버킷계수 k", ""], + ["Es", "작업효율 Es", ""], + ["Cms", "적재기계 Cms", "초"], + ], + [ + ["t3", "적하 t3", "분"], + ["t4", "대기 t4", "분"], + ["t5", "덮개 t5", "분"], + ["t6", "세륜 t6", "분"], + ], +]; +/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */ +const PICKS: Array<[string, Record, string]> = [ + ["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"], + ["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"], + ["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"], + ["CC000056", { 시간: "t4" }, "대기(적재장소)"], + ["CC000057", { 시간: "t5" }, "덮개"], + ["CC000058", { 시간: "t6" }, "세륜"], +]; +const OWNABLE = new Set(["L1", "L2", "t1"]); +const FROM_DIST = "거리에서"; + +const fmt = (v: number | null | undefined): string => + v == null || !Number.isFinite(v) ? "—" : String(v); + +/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */ +export function cycleLines(input: Record): string[] { + const v: Record = { ...CYCLE_DEFAULTS }; + for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; + const r = cycleTime(input); + const t1 = + typeof input.t1 === "number" + ? `t1 = ${fmt(r.t1)} 분(손 입력)` + : `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`; + return [ + "사이클시간 — 건설품셈 8-2-8 덤프트럭", + `q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`, + `n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`, + t1, + `t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`, + `Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`, + `운반거리 = L1 + L2 = ${r.L} km`, + ]; +} + +interface Preview { + lines: string[]; + unit: string; + price: number | null; + why: string | null; +} + +async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) throw new Error("프로젝트 없음"); + const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as Preview; +} + +const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text); + +/** 「운반 단가」 모달 */ +export function openHaulModal(o: HaulModalOpts): void { + const logicRef = o.ref?.tab === "logic" ? o.ref : null; + const st = { + code: String(logicRef?.code ?? ""), + name: String(logicRef?.name ?? ""), + given: { ...((logicRef?.given as Row) || {}) } as Row, + cycle: { ...((logicRef?.cycle as Row) || {}) } as Row, + own: new Set((logicRef?.own as string[]) || []), + rate: logicRef?.rate ?? null, + picks: logicRef?.picks ?? null, + inputs: [] as ItemInput[], + unit: "", + }; + let seq = 0; + let timer = 0; + openModal({ + title: `운반 단가 — ${o.name}`, + closeLabel: "닫기", + wide: true, + dialogClass: "b09-haul-modal", + mount: (body, close, head) => { + const title = el("strong"); + const linkBox = el("div", { + attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" }, + }); + const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } }); + const steps = el("pre", { + attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" }, + }); + const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] }); + const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given }); + const names = (): string[] => st.inputs.map((i) => i.이름); + const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; + + const redraw = (): void => { + const input = cycleInputs(ref(), o.dist); + const head2 = cycleLines(input); + steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join( + "\n", + ); + window.clearTimeout(timer); + if (!st.code) return; + const my = ++seq; + timer = window.setTimeout(() => { + preview(st.code, filled(), st.rate, st.picks).then( + (got) => { + if (my !== seq) return; // 최신 응답만 + st.unit = got.unit; + const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`; + steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n"); + }, + (error: unknown) => { + if (my === seq) + steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n"); + }, + ); + }, 250); + }; + + const numberField = (name: string, text: string, unit: string): HTMLElement => { + const input = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "number", step: "any", "data-haul-cycle": name }, + }) as HTMLInputElement; + const tag = el("span", { className: "m02-labin__muted" }); + const show = (): void => { + const now = cycleInputs(ref(), o.dist); + const auto = + name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]); + const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle; + if (document.activeElement !== input) + input.value = mine ? String(st.cycle[name] ?? "") : ""; + input.placeholder = String(auto ?? ""); + tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : ""; + }; + input.addEventListener("change", () => { + const v = input.value.trim() === "" ? null : Number(input.value); + if (v === null || !Number.isFinite(v)) { + delete st.cycle[name]; + st.own.delete(name); + } else { + st.cycle[name] = v; + if (OWNABLE.has(name)) st.own.add(name); + } + update(); + }); + shows.push(show); + return row(label(text, unit), [input, tag]); + }; + const row = (text: string, cells: HTMLElement[]): HTMLElement => + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text }), + el("div", { className: "m02-inputval__cell", children: cells }), + ], + }); + const shows: Array<() => void> = []; + + const tablePick = (key: string, map: Record, text: string): HTMLElement => { + const select = el("select", { + className: "ui-select ui-select--sm m02-inputval__control", + attrs: { "data-haul-pick": Object.values(map).join(",") }, + }) as HTMLSelectElement; + select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } })); + void loadTable(key).then((t: TableRow | null) => { + const conds = Object.keys(t?.조건 || {}); + (t?.줄 || []).forEach((line, i) => { + const what = conds.map((c) => String(line[c] ?? "")).join(" · "); + const vals = Object.keys(map) + .map((col) => `${map[col]} ${String(line[col] ?? "")}`) + .join(" · "); + select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } })); + }); + select.onchange = () => { + const line = t?.줄?.[Number(select.value)]; + if (!line) return; + for (const [col, name] of Object.entries(map)) + if (typeof line[col] === "number") st.cycle[name] = line[col] as number; + select.value = ""; + update(); + }; + }); + return row(text, [select]); + }; + + const logicRows = (): HTMLElement[] => + st.inputs.map((input) => { + const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름); + const value = fill ? filled()[input.이름] : st.given[input.이름]; + const field = createValueControl( + input, + (value as string | number | null) ?? null, + (next, bad) => { + if (bad) return; + if (next === null || next === "") { + delete st.given[input.이름]; + st.own.delete(input.이름); + } else { + st.given[input.이름] = + typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next; + if (input.이름 in CYCLE_FILL) st.own.add(input.이름); + } + update(); + }, + ); + field.control.setAttribute("data-labor-input", input.이름); + const tag = el("span", { className: "m02-labin__muted" }); + shows.push(() => { + const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름); + tag.textContent = auto ? FROM_DIST : ""; + if (auto && document.activeElement !== field.control) + field.control.value = String(filled()[input.이름] ?? ""); + }); + return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); + }); + + /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ + const update = (): void => { + shows.forEach((show) => show()); + redraw(); + }; + /** 로직 바뀜 — 왼쪽 칸을 새로 */ + const refresh = (): void => { + shows.splice(0); + const sec = (text: string): HTMLElement => + el("h4", { text, attrs: { style: "margin:12px 0 4px" } }); + left.replaceChildren( + sec("단가산출 옵션 값"), + ...(st.code + ? logicRows() + : [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]), + sec("사이클시간 입력"), + ...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))), + sec("표에서 고르기(건설품셈 8-2-8)"), + ...PICKS.map(([key, map, text]) => tablePick(key, map, text)), + ); + title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음"; + change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기"; + update(); + }; + + const useLogic = (code: string, name: string): void => { + fetchLogicInputs([code]).then( + (got) => { + st.code = code; + st.name = name; + st.inputs = got.로직[0]?.입력 ?? []; + const keep = new Set(st.inputs.map((i) => i.이름)); + for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k]; + refresh(); + }, + (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), + ); + }; + const change = createButton({ + label: "변경", + variant: "ghost", + size: "sm", + onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)), + }); + linkBox.append(title, change); + if (o.other) + linkBox.append( + createButton({ + label: "다른 종류", + variant: "ghost", + size: "sm", + onClick: () => { + close(); + o.other?.(); + }, + }), + ); + const ok = createButton({ + label: "적용", + onClick: () => { + if (!st.code) return showToast("단가산출을 먼저 고르세요", "error"); + const next: Row = { + tab: "logic", + code: st.code, + name: st.name, + given: filled(), + rate: st.rate, + picks: st.picks, + cycle: st.cycle, + own: [...st.own], + }; + o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") }); + showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success"); + close(); + }, + }); + head.append(ok); + body.append( + linkBox, + el("div", { + attrs: { + style: + "display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh", + }, + children: [left, right], + }), + ); + if (st.code) useLogic(st.code, st.name); + else refresh(); + }, + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 35a5dcb81..99243bdb4 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -225,7 +225,13 @@ function linkFor(ctx: TabContext, rows: DataRow[], i: number) { const row = rows[i]; const e = entryOf(freeze(ctx.user, rows), String(row.key)); const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null); - return linkItems(ctx, ref, applyRef(ctx, rows, i)); + const key = String(row.key); + // 253-3 운반공 줄 = 「운반 단가」 모달(거리 = 서버가 준 항목 `dist`) + const item = key.startsWith("haul:") + ? ((ctx.payload.inputs?.bill?.items as Row[]) || []).find((it) => it.key === key) + : undefined; + const haul = item && { dist: item.dist ?? null, name: String(row.name ?? item.name ?? "") }; + return linkItems(ctx, ref, applyRef(ctx, rows, i), haul); } /** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index c8293beb6..5831e654c 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -19,7 +19,9 @@ import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import type { Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { openHaulModal } from "./B09_Estimation_UI_Haul_Modal"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; /** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */ @@ -95,22 +97,27 @@ async function importLib( } } -/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 */ -export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { +/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 · + * haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달(옛 창은 [다른 종류]) */ +export function linkItems( + ctx: TabContext, + ref: Row | null, + apply: ApplyRef, + haul?: { dist: Dist; name: string }, +): [Leaf[], Leaf[]] { const more: Leaf[] = []; - if (ref?.tab === "logic") + const priceLink = (): void => + void openPriceLink({ + apply, + hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, + onPayload: (p) => Object.assign(ctx.payload, p), + }); + const haulModal = (): void => + openHaulModal({ ref, dist: haul?.dist, name: haul?.name || "", apply, other: priceLink }); + if (ref?.tab === "logic" && !haul) more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]); if (ref) more.push(["단가연결 풀기", () => apply(null)]); - const open: Leaf = [ - "단가연결", - () => - openPriceLink({ - apply, - hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, - onPayload: (p) => Object.assign(ctx.payload, p), - }), - ]; - return [[open], more]; + return [[["단가연결", haul ? haulModal : priceLink]], more]; } /** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 · diff --git a/resources/tester/test_253_3_haul_preview.py b/resources/tester/test_253_3_haul_preview.py new file mode 100644 index 000000000..730812d0c --- /dev/null +++ b/resources/tester/test_253_3_haul_preview.py @@ -0,0 +1,62 @@ +"""253-3 미리보기 길 `POST …/estimation/logic-preview` + 「운반 단가」 모달 — 거리이정표 우클릭 · +설계내역서 운반공 줄에서 엶.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import PreviewBody, logic_preview, router # noqa: E402 + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_미리보기_계산_절차(tmp_path): + cm = cycle_time({"L1": 25.1, "L2": 0.8})["cm"] + given = {"qt": 15, "f": 1, "E": 0.9, "Cm": cm, "지역": "전국평균"} + got = logic_preview(tmp_path, PreviewBody(code="GF000134", given=given)) + assert got["unit"] == "㎥" and got["price"] and got["price"] > 0 + assert "Cm : 109.78 분" in got["lines"] + assert any("60 * 15 * 1 * 0.9 / 109.78" in line for line in got["lines"]) + assert got["lines"][-1].startswith("계 :") + # 거리 늘면 단가도 늘음 + far = logic_preview(tmp_path, PreviewBody(code="GF000134", given={**given, "Cm": 200})) + assert far["price"] > got["price"] + + +def test_미리보기_길(): + paths = {(r.path, tuple(r.methods)) for r in router.routes} + assert ("/api/projects/{project_id}/estimation/logic-preview", ("POST",)) in paths + + +def test_내역_줄_거리(): + one = {"ref": None} + routes = [ + {**one, "key": "haul:3", "name": "모래운반", "user": False, "L1": 25.1, "L2": 0.8}, + {**one, "key": "haul:r-u1", "name": "깬돌운반", "user": True, "L1": 3, "L2": 1}, + ] + items = {i["key"]: i for i in haul_items(None, {}, routes)} + assert items["haul:3"]["dist"] == {"L1": 25.1, "L2": 0.8} + assert items["haul:r-u1"]["dist"] == {"L1": 3, "L2": 1} + assert items["haul:1"]["dist"] is None + + +def test_모달_여는_곳(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven", + "cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip + assert want in modal, want + milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "openHaulModal({" in milestone and "L1: paved" in milestone + link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") + assert '[["단가연결", haul ? haulModal : priceLink]]' in link + edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") + assert ( + 'key.startsWith("haul:")' in edit + and "linkItems(ctx, ref, applyRef(ctx, rows, i), haul)" in edit + ) From 2c79ff90464942d3abdef1868e5da9d4627fb43d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:11:39 +0900 Subject: [PATCH 049/151] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=3D=20=EC=A0=84=EC=B2=B4=20=EA=B2=BD=EB=A1=9C(?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=B2=98=20=E2=86=92=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=A0=90=20=EC=82=AC=EC=8A=AC=20=E2=86=92=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?)=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EC=A7=80?= =?UTF-8?q?=EB=AC=B8=20=EB=B0=94=EB=80=90=20=EA=B2=BD=EB=A1=9C=EB=A7=8C=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20(256-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- B08_Quantity/B08_Quantity_Router_Milestone.py | 58 +++++++- .../test_256_1_b08_milestone_place_image.py | 2 + .../test_256_2_b08_milestone_route_image.py | 140 ++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_256_2_b08_milestone_route_image.py diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 677de2218..5f33376bf 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -32,6 +32,10 @@ 다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 · GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms} (캐시 깨기). +경로 그림(256-2) = [저장] 뒤 같은 길로 경로 탭과 같은 전체 경로(거래처 → 기준점 사슬 → 시점 · +구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · 표지 = `mapMarks` 차례) → `auto/<경로 id>.png` +· 지문(표지 · 수기) 바뀐 경로만 · 화면 [길찾기] 그림(거래처 구간만)이 들어오면 지문을 지워 다음 +[저장]에 전체로. 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). @@ -62,7 +66,13 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes -from B08_Quantity.B08_Quantity_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( + SITE, + base_chain, + compute_milestone, + migrate_milestone, + r3, +) from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets @@ -314,6 +324,36 @@ def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: flo return got +def route_chain(data: dict[str, Any], route: dict[str, Any], start: list[float] | None) -> list: + """경로 그림 사슬 — [(이름, 경도, 위도, 수기)] · 화면 `mapMarks` 와 같은 차례 · + 좌표 없는 상자 뺌.""" + owners = [(route.get("name") or "거래처", route)] + if route.get("linked", True) is not False: + owners += [ + (b.get("name") or "기준점", b) for b in base_chain(data, route.get("to") or SITE) + ] + out = [(n, o.get("lon"), o.get("lat"), bool(o.get("manual"))) for n, o in owners] + if start: + out.append(("시점", start[0], start[1], False)) + return [c for c in out if c[1] is not None and c[2] is not None] + + +def route_image(root: str | Path, route_id: str, chain: list) -> dict[str, Any]: + """경로 그림 한 장 — 구간마다 카카오 길 선(수기 · 실패 = 곧은 선) → 캡처 → 지문 적음.""" + lines = [] + for (_, lon1, lat1, manual), (_, lon2, lat2, _) in zip(chain, chain[1:]): + path = [] + if not manual: + got = directions_km(LonLat(lon=lon1, lat=lat1), LonLat(lon=lon2, lat=lat2)) + path = got.get("path") or [] + lines.append(path or [[lat1, lon1], [lat2, lon2]]) + marks = [{"name": n, "lon": lon, "lat": lat} for n, lon, lat, _ in chain] + got = make_route_image(folder(root) / AUTO, route_id, lines, marks) + if got["saved"]: + _set_print(root, f"{AUTO}/{route_id}", _print(chain)) + return got + + def refresh_images(root: str | Path) -> None: """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 · 실패 = 다음 [저장]에 다시.""" @@ -323,6 +363,15 @@ def refresh_images(root: str | Path) -> None: for place_id, name, lon, lat in places(data): if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have: place_image(root, place_id, name, lon, lat) + start = site_info(root, None)["start_lonlat"] + have = images(root, AUTO) + for route in data["routes"]: + chain = route_chain(data, route, start) + key = f"{AUTO}/{route['id']}" + if route.get("linked", True) is False or len(chain) < 2: + continue # 안 이은 거래처 = 경로 그림 없음 + if prints.get(key) != _print(chain) or route["id"] not in have: + route_image(root, route["id"], chain) def _route_id(route: str) -> str: @@ -353,7 +402,10 @@ async def milestone_save( ) -> dict[str, Any]: root = await _project_root(project_id) out = await asyncio.to_thread(save_data, root, body) - tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 + if not os.environ.get( + "PYTEST_CURRENT_TEST" + ): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름 + tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 return out @@ -464,6 +516,8 @@ async def milestone_image_auto( lines = [[list(p) for p in line] for line in body.lines] marks = [m.model_dump() for m in body.marks] got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks) + if got["saved"]: # 거래처 구간만 그림 — 다음 [저장]에 전체 경로로 다시(256-2) + await asyncio.to_thread(_set_print, root, f"{AUTO}/{route_id}", None) return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py index 316947fd8..63553beab 100644 --- a/resources/tester/test_256_1_b08_milestone_place_image.py +++ b/resources/tester/test_256_1_b08_milestone_place_image.py @@ -55,6 +55,7 @@ def _save(root, data): def _fake(monkeypatch): fake = Fake() monkeypatch.setattr(ms, "make_place_image", fake) + monkeypatch.setattr(ms, "route_image", lambda *a: {"saved": False}) # 경로 그림 = 256-2 시험 return fake @@ -121,6 +122,7 @@ def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch): return tmp_path monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠) app = FastAPI() app.include_router(ms.router) client = TestClient(app) diff --git a/resources/tester/test_256_2_b08_milestone_route_image.py b/resources/tester/test_256_2_b08_milestone_route_image.py new file mode 100644 index 000000000..50dd56306 --- /dev/null +++ b/resources/tester/test_256_2_b08_milestone_route_image.py @@ -0,0 +1,140 @@ +"""256-2 B08 거리이정표 경로 그림 = 전체 경로(사용자 162-7). + +캡처 · 길찾기는 가짜로: +① 사슬 = 거래처 → 기준점 사슬(254 bases · to) → 시점 · 화면 `mapMarks` 차례 · 좌표 없는 상자 뺌 +② 구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · auto/<경로 id>.png +③ [저장] 뒤 지문 비교 — 안 바뀜 = 0 · 기준점 · 시점 옮김 = 그 사슬 경로만 · 안 이은 거래처 = 없음 · + 화면 [길찾기] 그림(거래처 구간만)이 들어오면 다음 [저장]에 전체로 +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-route" +START = [127.5, 35.4] +BASES = [ + {"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"}, + {"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52, "manual": True}, +] +ROUTES = [ + {"id": "r1", "name": "거창레미콘", "lon": 127.95, "lat": 35.70, "to": "b1"}, + {"id": "r2", "name": "직송", "lon": 127.60, "lat": 35.45}, + {"id": "r3", "name": "안 이음", "lon": 127.61, "lat": 35.46, "linked": False}, + {"id": "r4", "name": "좌표 없음", "to": "b2"}, +] +DATA = {"bases": BASES, "routes": ROUTES} + + +class Fake: + def __init__(self): + self.shots = [] # (경로 id, lines, marks) + self.asked = [] # 길찾기 구간 + + def shoot(self, out_dir, route_id, lines, marks): + self.shots.append((route_id, lines, marks)) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{route_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + def directions(self, origin, destination): + self.asked.append((origin.lon, destination.lon)) + if origin.lon == 127.60: # r2 → 시점 = 길찾기 실패 + return {"km": None, "reason": "실패"} + return {"km": 1.0, "reason": None, "path": [[origin.lat, origin.lon], [0, 0]]} + + +def _setup(tmp_path, monkeypatch, start=START): + fake = Fake() + box = {"start": start} + monkeypatch.setattr(ms, "make_route_image", fake.shoot) + monkeypatch.setattr(ms, "directions_km", fake.directions) + monkeypatch.setattr(ms, "make_place_image", lambda *a: {"saved": False}) # 256-1 시험 + monkeypatch.setattr( + ms, "site_info", lambda root, mid: {"start_lonlat": box["start"], "mid_default_m": None} + ) + return fake, box + + +def _save(root, data): + ms.save_data(root, ms.Milestone.model_validate(data)) + ms.refresh_images(root) + + +def test_사슬_화면_차례(): + data = ms.Milestone.model_validate(DATA).model_dump() + chain = ms.route_chain(data, data["routes"][0], START) + assert chain == [ + ("거창레미콘", 127.95, 35.70, False), + ("거창IC", 127.91, 35.68, False), + ("함양JC", 127.72, 35.52, True), + ("시점", 127.5, 35.4, False), + ] + assert [c[0] for c in ms.route_chain(data, data["routes"][3], START)] == ["함양JC", "시점"] + assert [c[0] for c in ms.route_chain(data, data["routes"][1], None)] == ["직송"] + + +def test_전체_경로_그림_구간_선(tmp_path, monkeypatch): + fake, _ = _setup(tmp_path, monkeypatch) + _save(tmp_path, DATA) + shots = {rid: (lines, marks) for rid, lines, marks in fake.shots} + assert sorted(shots) == ["r1", "r2", "r4"] # r3 안 이음 = 없음 + lines, marks = shots["r1"] + assert [m["name"] for m in marks] == ["거창레미콘", "거창IC", "함양JC", "시점"] + assert len(lines) == 3 + assert lines[0] == [[35.70, 127.95], [0, 0]] # 카카오 길 선 + assert lines[2] == [[35.52, 127.72], [35.4, 127.5]] # 수기 기준점 = 곧은 선 · 길찾기 안 함 + assert (127.72, 127.5) not in fake.asked + assert shots["r2"][0] == [[[35.45, 127.60], [35.4, 127.5]]] # 길찾기 실패 = 곧은 선 + assert (tmp_path / "B08_Quantity/milestone/auto/r1.png").read_bytes() == PNG + + +def test_바뀐_경로만_다시(tmp_path, monkeypatch): + fake, box = _setup(tmp_path, monkeypatch) + _save(tmp_path, DATA) + fake.shots.clear() + ms.refresh_images(tmp_path) + assert fake.shots == [] # 안 바뀜 + moved = {**DATA, "bases": [BASES[0], {**BASES[1], "lat": 35.55}]} + _save(tmp_path, moved) + assert sorted(s[0] for s in fake.shots) == ["r1", "r4"] # b2 를 지나는 경로만 + fake.shots.clear() + box["start"] = [127.4, 35.3] # 시점 옮김(노선 수정) = 모두 + ms.refresh_images(tmp_path) + assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"] + fake.shots.clear() + (tmp_path / "B08_Quantity/milestone/auto/r2.png").unlink() # 그림 없음 = 다시 + ms.refresh_images(tmp_path) + assert [s[0] for s in fake.shots] == ["r2"] + + +def test_길찾기_그림_뒤엔_다음_저장에_전체로(tmp_path, monkeypatch): + fake, _ = _setup(tmp_path, monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + assert client.put(base, json=DATA).status_code == 200 # 답 뒤 캡처 + assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"] + fake.shots.clear() + part = {"lines": [[[35.70, 127.95], [35.68, 127.91]]], "marks": []} + assert client.post(base + "/image/auto", params={"route": "r1"}, json=part).status_code == 200 + assert [s[0] for s in fake.shots] == ["r1"] # 화면 길찾기 그림(거래처 구간만) + fake.shots.clear() + ms.refresh_images(tmp_path) + assert [(s[0], len(s[2])) for s in fake.shots] == [("r1", 4)] # 전체 경로로 다시 From 41f68b8178df11f57f4bca7d9e45773fc9767617 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:12:24 +0900 Subject: [PATCH 050/151] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=EC=83=81=EC=9E=90=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=A0=9C=EB=AA=A9=20=C2=B7=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=95=88=20=EC=9E=A5=EC=86=8C=20=EA=B7=B8=EB=A6=BC(254-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 항목 정보 없음) - 장소 그림(256-1 place_images) = 상자 글 아래 132 × 88 · 그림 없으면 자리 없음 · 키 바뀔 때만 다시 받음 - 시험 = test_254_3 새로 · 244_6 · 244_10 node 입력 = stdin(명령 줄 길이) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 13 ++- B08_Quantity/B08_Quantity_UI_Milestone.css | 5 ++ .../B08_Quantity_UI_Milestone_Diagram.ts | 37 ++++++++- .../B08_Quantity_UI_Milestone_Modal.ts | 2 +- .../test_244_10_b08_milestone_tailscale.py | 3 +- .../test_244_6_b08_milestone_pan_client.py | 3 +- .../test_254_3_b08_milestone_box_modal_pic.py | 81 +++++++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_254_3_b08_milestone_box_modal_pic.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 2c99a0062..a07c8f8c5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -38,6 +38,8 @@ export interface DiagramBox { notes?: string[]; /** 호버 = 줄이지 않은 주소 */ tip?: string; + /** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */ + pic?: Rect; } export interface DiagramLine { @@ -101,6 +103,8 @@ const KM_LINE = 16; /** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */ export const ROW = 15; const ROW_PAD = 13; +/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */ +export const PIC = { w: 132, h: 88 }; const NOTE_LINES = 3; /** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */ @@ -421,8 +425,10 @@ export function layoutMilestone( mid_address?: string | null; } | null, midKm: number, + pics: ReadonlySet = new Set(), // 254-3 장소 그림 있는 상자 id ): DiagramLayout { const boxes: DiagramBox[] = []; + const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0); const pending: Array<[string, string, Leg[]]> = []; const byId = new Map(); const center = (id: string): [number, number] => { @@ -523,7 +529,7 @@ export function layoutMilestone( const [ax, ay] = center(anchor); const [ux, uy] = unit(deg); const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : []; - const h = boxH(1 + notes.length); + const h = boxH(1 + notes.length) + picH(b.id); const reach = reachOf(anchor, h, ux, uy, b.legs); put( { @@ -583,7 +589,7 @@ export function layoutMilestone( // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 linked: r.linked !== false && (r.to === "site" || anchor === r.to), notes, - h: boxH((items ? 1 : 0) + 1 + notes.length), + h: boxH((items ? 1 : 0) + 1 + notes.length) + picH(r.id), }); } for (const group of groups.values()) { @@ -616,6 +622,9 @@ export function layoutMilestone( }); } + // 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음) + for (const b of boxes) + if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC }; const lines = pending.map(([from, to, legs]) => edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!), ); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 2c7509eae..80cc552d4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -183,6 +183,11 @@ font-size: 10px; } +/* 254-3 상자 안 장소 그림 — 누름 · 끌기는 상자 몫 */ +.b08-milestone__pic { + pointer-events: none; +} + /* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */ .b08-milestone__map { width: 100%; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 548c2b7f4..a6178f573 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -13,6 +13,7 @@ * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) · * 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km) * 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소) + * 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음 * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ @@ -42,6 +43,8 @@ import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milesto import { findSegmentKm, liveMilestone, + placeImageBlobUrl, + placeImageKey, putMilestone, roadOptions, siteAddress, @@ -298,6 +301,18 @@ export function createDiagram( addElem.dataset.b08MilestoneAdd = "open"; const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] }); + /** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */ + const picUrls = new Map }>(); + const picUrl = (id: string, key: string): Promise => { + let got = picUrls.get(id); + if (!got || got.key !== key) { + got?.url.then(URL.revokeObjectURL, () => undefined); + got = { key, url: placeImageBlobUrl(projectId, id) }; + picUrls.set(id, got); + } + return got.url; + }; + const refresh = (): void => { const data = liveMilestone(projectId); const site = siteInfo(); @@ -313,6 +328,7 @@ export function createDiagram( mid_address: addr?.mid_address, }, mk, + new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))), ); // 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠 // (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음) @@ -361,9 +377,10 @@ export function createDiagram( ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]), ]; const x = box.x + box.w / 2; + const th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위 const text = svg("text", { x, - y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2, + y: box.kind === "site" ? box.y + 18 : box.y + th / 2 - ((rows.length - 1) * 15) / 2, "text-anchor": "middle", "dominant-baseline": "central", }); @@ -371,6 +388,24 @@ export function createDiagram( text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)), ); g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text); + // 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움) + const key = box.pic && placeImageKey(box.id); + if (box.pic && key) { + const { x: px, y: py, w: pw, h: ph } = box.pic; + const pic = svg("image", { + class: "b08-milestone__pic", + x: px, + y: py, + width: pw, + height: ph, + }); + pic.setAttribute("preserveAspectRatio", "xMidYMid slice"); + g.append(pic); + picUrl(box.id, key).then( + (url) => pic.setAttribute("href", url), + () => pic.remove(), + ); + } if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소 // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 476ede05d..a072c9878 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -104,7 +104,7 @@ export function openPlaceModal( ? "요소 추가 — 기준점 · 거래처" : base ? `기준점 — ${base.name || "기준점"}` - : `항목 정보 — ${route!.name || "거래처"}`; + : `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음 openModal({ title, closeLabel: "닫기", diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index 9d4f13294..c4bbfbc45 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -59,9 +59,10 @@ console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hi """ ) src = IMPORTS.sub("", _read(LAYOUT)) + script + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src], + "--input-type=module-typescript", "-"], input=src, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 93228518e..cef51aaa2 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -27,9 +27,10 @@ def _node(script): IMPORTS.sub("", _read(rel)) for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts") ) + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src + "\n" + script], + "--input-type=module-typescript", "-"], input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr diff --git a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py new file mode 100644 index 000000000..aeec089ba --- /dev/null +++ b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py @@ -0,0 +1,81 @@ +"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8). + +① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음) +② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=src + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [{"id": "B", "name": "병곡리", "address": "거창읍", "dir": None, "to": "site", + "legs": [{"road": None, "km": 0.6}], "linked": True}], + "routes": [ + {"id": "g", "name": "거창군청", "address": "거창읍", "dir": "N", "to": "B", + "legs": [{"road": None, "km": 23.8}], + "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "k", "name": "김천역", "address": "", "dir": "E", "to": "site", + "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_장소_그림_상자_안_글_아래(): + d = json.dumps(DATA, ensure_ascii=False) + plain, with_pic = _node( + f"[layoutMilestone({d}, null, 0.5), layoutMilestone({d}, null, 0.5, new Set(['B', 'g']))]" + ) + a = {b["id"]: b for b in plain["boxes"]} + b = {b["id"]: b for b in with_pic["boxes"]} + pic = _node("PIC") + assert pic == {"w": 132, "h": 88} # 서버 480 × 320 과 같은 3:2 · 상자 안쪽 폭 + # 그림 없음 = 자리 없음(높이 그대로 · pic 없음) + assert all("pic" not in box for box in a.values()) + assert "pic" not in b["k"] and b["k"]["h"] == a["k"]["h"] + for bid in ("B", "g"): + box, before = b[bid], a[bid] + assert box["h"] == before["h"] + pic["h"] + 8 # 그림 + 아래 여백만큼 늘림 + p = box["pic"] + # 안쪽 여백 안 · 글 아래(글 자리 = 그림 위 높이 = 그림 없을 때 높이) + assert p["x"] == box["x"] + 8 and p["x"] + p["w"] == box["x"] + box["w"] - 8 + assert p["y"] - box["y"] == before["h"] and p["y"] + p["h"] == box["y"] + box["h"] - 8 + + +def test_모달_제목_화면_그림(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal + assert "항목 정보 —" not in modal + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui + assert 'pic.setAttribute("href", url)' in ui and "() => pic.remove()" in ui + assert "if (!got || got.key !== key)" in ui # 키(이름#시각) 바뀔 때만 다시 받음 + assert "const th = box.pic ? box.pic.y - box.y : box.h;" in ui # 글 = 그림 위 가운데 + css = _read(B08 + "UI_Milestone.css") + pic = css[css.index(".b08-milestone__pic {") :] + assert "pointer-events: none;" in pic[: pic.index("}")] # 누름 · 끌기 = 상자 몫 + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name From 96c9eaed37b7ba9b087bec63e761ee86de03e30b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:16:46 +0900 Subject: [PATCH 051/151] =?UTF-8?q?feat(b07):=20=EC=95=88=20=EC=97=B0=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=84=A3=EC=9D=8C=20=E2=80=94=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=EC=8D=AC=20=EC=A7=9D=20(260-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 B07_DesignDetail_Engine_Cad_CulvertNote = TS culvertNoteEntities · stationNoLabel 짝(두 파일 머리 표시) - 횡단 장 만들 때 CAD 칸 없음 · 옛 글 없음 · 배수관 주인 측점 = 정본 pipe_points 로 미리 넣음 칸을 얹은 사본 - 지운 칸(빈 엔티티) · 옛 글 · 설계 없음 · 이웃 측점 = 그대로 - 시험 test_260_note_cad 거울 9쌍(같은 입력 → 같은 엔티티) · 정본 읽기 · B07 그림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B06_Section_UI_Cross_Detail_Cad.ts | 3 + ...B07_DesignDetail_Engine_Cad_CulvertNote.py | 231 ++++++++++++++++++ .../B07_DesignDetail_Router_Support.py | 18 +- resources/tester/helper_260_note_cad.cjs | 13 +- resources/tester/test_260_note_cad.py | 157 ++++++++++++ 5 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index 6633cfba7..3cb8b04b4 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -7,6 +7,9 @@ * (`_Engine_Cad_Table.cross_note_cad_entities`). * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수 * 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면. + * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` · + * `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 · + * 거울 시험 `test_260_note_cad.py`. * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. * ========================================================================== */ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py new file mode 100644 index 000000000..53dc81fa1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -0,0 +1,231 @@ +"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21). + +⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` · +`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`. +B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸 +(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저 +(모달이 처음 열 때 옮기는 차례와 같음). +좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. +""" + +import json +import math +import re +from pathlib import Path +from typing import Any + +from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file +from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points + +# TS `PIPE_MATCH_M`(`design_selection`) — 관 자리와 측점이 같은 자리로 보는 폭 +PIPE_MATCH_M = 0.51 +_LAYER_ID = "기본" +_FONT = 2.0 +_LINE = 3.0 +_COLOR = "#ff7f7f" +_BASIN_FORMS = {"I": "I형", "L": "L형", "U": "ㄷ형"} + + +def _js_round(value: float) -> int: + """JS `Math.round` — .5 는 위로.""" + return math.floor(value + 0.5) + + +def _one(value: Any) -> str: + ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + return f"{value:.1f}" if ok else "" + + +def _metres(value: Any) -> str: + return f"{_one(value)}m" if _one(value) else "" + + +def _trim(value: float) -> str: + rounded = _js_round(value * 100) / 100 + return str(int(rounded)) if rounded == int(rounded) else str(rounded) + + +def _bond(form: Any) -> str: + """돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로.""" + text = str(form) if form is not None else "" + return "찰" if "찰" in text else "메" if "메" in text else text + + +def _side_line(side: Any, title: str) -> str | None: + if not isinstance(side, dict) or not side.get("structure"): + return None + basin = side["structure"] == "집수정" + form = ( + _BASIN_FORMS.get(side.get("basin_form") or "", "") + if basin + else _bond(side.get("revet_form")) + ) + height = side.get("basin_height_m" if basin else "revet_height_m") + length = side.get("basin_length_m" if basin else "revet_length_m") + spec = ",".join(part for part in (form, f"H={_one(height)}") if part) + return f"관보호공({title}) {side['structure']}({spec}) L={_metres(length)}" + + +def _area(value: Any) -> float: + """JS `Number()` 꼴 — 빈 글 = 0 · 못 읽음 = nan.""" + if value is None: + return math.nan + if isinstance(value, str) and not value.strip(): + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return math.nan + + +def _chute_line(options: dict[str, Any], fallback: str | None) -> str | None: + picked = sorted( + int(hit.group(1)) + for key, value in options.items() + if (hit := re.match(r"^chute_add_(\d+)_structure$", key)) + and str(value if value is not None else "").strip() + ) + head = f"chute_add_{picked[0]}_" if picked else None + structure = str(options[f"{head}structure"]).strip() if head else fallback + if not structure: + return None + bond_raw = options.get(f"{head}bond") if head else None + bond = _bond(bond_raw if bond_raw is not None else "") if head else "" + area = _area(options.get(f"{head}area")) if head else math.nan + spec = ",".join(part for part in (structure, bond) if part) + return f"관유출부보강({spec}) {_trim(area) if area > 0 else ''}m2" + + +def station_no_label(chainage_m: float, interval_m: float) -> str: + """측점 이름(대문자 NO · 거리 정수) — TS `stationNoLabel` 짝.""" + safe = interval_m if interval_m > 0 else 1.0 + number = math.floor((chainage_m + 1e-6) / safe) + rest = chainage_m - number * safe + if rest >= safe - 0.05: + number += 1 + rest = 0.0 + return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}" + + +def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]: + return { + "id": f"culvert-note:{index}", + "type": kind, + "lineColor": _COLOR, + "lineWidth": 1, + "layerId": _LAYER_ID, + "shapeData": shape_data, + } + + +def _text(index: int, label: str, x: float, y: float, align: str = "left") -> dict[str, Any]: + return _shape( + index, + "Text", + { + "label": label, + "basePoint": {"x": x, "y": y}, + "options": { + "textDirection": {"x": 1, "y": 0}, + "textAlign": align, + "textColor": _COLOR, + "fontSize": _FONT, + "fontFamily": "sans-serif", + }, + }, + ) + + +def culvert_note_entities( + chainage_m: float, + culvert: dict[str, Any] | None, + design: dict[str, Any] | None, + station: str, + options: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + """횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록. + + `culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션. + """ + if not isinstance(culvert, dict) or culvert.get("type") != "pipe": + return [] + if culvert.get("body") or culvert.get("hidden_pipe"): + return [] + owner = culvert.get("chainage_m") + if abs((owner if isinstance(owner, (int, float)) else chainage_m) - chainage_m) >= PIPE_MATCH_M: + return [] + cx, cy, radius = 6.5, -9.0, 6.5 + length = design.get("pipe_length_m") if isinstance(design, dict) else None + inside = [ + "배수관", + f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m", + f"L={_metres(length)}", + ] + chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {} + right = [ + line + for line in ( + station, + _side_line(culvert.get("inlet"), "유입"), + _side_line(culvert.get("outlet"), "유출"), + _chute_line(options or {}, chute.get("structure")), + ) + if line + ] + entities = [ + _shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}), + _shape( + 1, + "Line", + { + "startPoint": {"x": cx, "y": cy - radius}, + "endPoint": {"x": cx, "y": cy - radius - _LINE}, + }, + ), + ] + entities += [ + _text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside) + ] + entities += [_text(5 + i, line, 15, -_LINE * (i + 0.5)) for i, line in enumerate(right)] + return entities + + +def load_pipe_notes(project_root: Path) -> list[tuple[float, dict[str, Any], dict[str, Any]]]: + """정본 `pipe_points.json` 의 배수관 — (관 누가거리, 세트, 옵션) · 관경 안 고름 = 관 없음.""" + path = pipe_points_file(project_root) + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + pipes = [] + for point in parse_pipe_points(document.get("points")): + options = dict(point.options or {}) + if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""): + continue + spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)} + pipes.append((float(point.chainage_m), spec, options)) + return pipes + + +def with_culvert_note( + design: dict[str, Any] | None, + chainage_m: float, + pipes: list[tuple[float, dict[str, Any], dict[str, Any]]], + station: str, +) -> dict[str, Any] | None: + """CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로. + + 설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈. + """ + if not isinstance(design, dict) or design.get("structure_note_cad") is not None: + return design + if str(design.get("structure_note") or "").strip(): + return design + near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M] + if not near: + return design + _at, spec, options = min(near, key=lambda pipe: abs(pipe[0] - chainage_m)) + entities = culvert_note_entities(chainage_m, spec, design, station, options) + if not entities: + return design + return {**design, "structure_note_cad": {"entities": entities}} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index a351e69c7..17056cc23 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -25,6 +25,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( + load_pipe_notes, + with_culvert_note, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( + station_no_label as station_no_label_upper, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -383,17 +390,22 @@ def _cross_section_input( chainage: int | float, design: dict[str, Any] | None, overlay: tuple[float, float, float, float] | None = None, + pipes: list[tuple[float, dict[str, Any], dict[str, Any]]] | None = None, ) -> dict[str, Any] | None: - """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.""" + """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다. + + `pipes` = 정본 배수관(`load_pipe_notes`) — CAD 칸 없는 주인 측점에 미리 넣음(260-4).""" path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage) if not path.is_file(): return None source = _read_json(path) interval = infer_station_interval(longitudinal.get("stations") or []) + at = float(source.get("chainage_m", chainage)) return { "chainage": chainage, "source": source, - "design": design, + # 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4) + "design": with_culvert_note(design, at, pipes or [], station_no_label_upper(at, interval)), "design_line": _cross_design_line(longitudinal_path, source, design), "quantity_table": _quantity_table(source, design), "title": station_no_label(float(source.get("chainage_m", chainage)), interval), @@ -416,6 +428,7 @@ def _read_cross_sheet( if sheet is None: raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") longitudinal = _read_json(longitudinal_path) + pipes = load_pipe_notes(project_root) sections = [ section for section in ( @@ -425,6 +438,7 @@ def _read_cross_sheet( chainage, designs.get(chainage), overlays.get(chainage), + pipes, ) for chainage in sheet["chainages"] ) diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs index 372e2a599..4cd84f09e 100644 --- a/resources/tester/helper_260_note_cad.cjs +++ b/resources/tester/helper_260_note_cad.cjs @@ -1,6 +1,6 @@ /* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음. * `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내). - * 결과는 표준출력에 JSON 한 벌. 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다. + * 결과는 표준출력에 JSON 한 벌 · 인자 `mirror` = 표준입력 입력으로 거울 결과(260-4). 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다. */ const fs = require("fs"); const path = require("path"); @@ -108,4 +108,13 @@ const out = { cad.stationNoLabel(1439.97, 20), ], }; -process.stdout.write(JSON.stringify(out)); +// 거울(260-4) — 시험이 표준입력으로 준 같은 입력 [{section, station, options, chainage, interval}] +// → TS 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조) +if (process.argv[2] === "mirror") { + const cases = JSON.parse(fs.readFileSync(0, "utf8")); + const mirrored = cases.map((item) => ({ + entities: cad.culvertNoteEntities(item.section, item.station, item.options), + station: cad.stationNoLabel(item.chainage, item.interval), + })); + process.stdout.write(JSON.stringify(mirrored)); +} else process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 2b408a5c4..5a85aed87 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -294,3 +294,160 @@ def test_CAD_칸이_없으면_옛_글_줄_그대로(): noted = _draw({"structure_note": "L형 측구\n집수정 보강"}) labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"] assert labels == ["L형 측구", "집수정 보강"] + + +# ── 260-4 안 연 측점 = B07 이 파이썬 짝으로 넣음 ───────────────────────── + +from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + culvert_note_entities, + load_pipe_notes, + station_no_label, + with_culvert_note, +) + +_OPTIONS = { + "pipe_diameter_mm": 1200, + "inlet_type": "기슭막이", + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_height_m": 2, + "inlet_revet_length_m": 10, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2, + "outlet_revet_length_m": 10, + "chute_add_1_structure": "돌붙임", + "chute_add_1_bond": "찰붙임", + "chute_add_1_area": 10, +} +_NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")} + + +def _case(options: dict, pipe_at: float, chainage: float, design: dict | None, **spec) -> dict: + culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec} + return { + "section": {"chainage_m": chainage, "culvert": culvert, "design": design}, + "station": station_no_label(chainage, 20.0), + "options": options, + "chainage": chainage, + "interval": 20.0, + } + + +_MIRROR_CASES = [ + _case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}), + _case(_NO_CHUTE, 1433.0, 1433.0, {}), + _case( + { + **_NO_CHUTE, + "inlet_type": "집수정", + "inlet_structure": "L", + "inlet_basin_height_m": 1.7, + "inlet_basin_length_m": 2, + }, + 1433.0, + 1433.0, + {"pipe_length_m": 9.5}, + ), + _case({**_NO_CHUTE, "chute_add_2_structure": "도수로", "chute_add_2_area": ""}, 80, 80, None), + _case({**_OPTIONS, "pipe_diameter_mm": 800, "chute_add_1_area": 12.345}, 12.5, 12.5, {}), + _case({**_OPTIONS, "inlet_revet_form": "콘크리트"}, 440.241, 440.0, {"pipe_length_m": 7}), + _case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}), + _case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"), + _case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True), +] + + +def _without_id(entities: list[dict]) -> list[dict]: + return [{k: v for k, v in entity.items() if k != "id"} for entity in entities] + + +def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], + input=json.dumps(_MIRROR_CASES, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + mirrored = json.loads(proc.stdout) + assert len(mirrored) == len(_MIRROR_CASES) + for case, ts in zip(_MIRROR_CASES, mirrored, strict=True): + section = case["section"] + py = culvert_note_entities( + section["chainage_m"], + section["culvert"], + section["design"], + case["station"], + case["options"], + ) + assert _without_id(py) == _without_id(ts["entities"]), case["station"] + assert station_no_label(case["chainage"], case["interval"]) == ts["station"] + # 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값 + assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3 + labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]] + assert "Φ800m/m" in labels and "관유출부보강(돌붙임,찰) 12.35m2" in labels + + +def _write_pipes(root: Path, points: list[dict]) -> None: + path = pipe_points_file(root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8") + + +def test_정본_배수관만_읽음(tmp_path): + _write_pipes( + tmp_path, + [ + {"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}, + {"chainage_m": 80.0, "lonlat": [0, 0], "facility": "pipe", "options": {}}, + {"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}}, + ], + ) + pipes = load_pipe_notes(tmp_path) + assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼 + assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS + assert load_pipe_notes(tmp_path / "없음") == [] + + +def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path): + _write_pipes( + tmp_path, + [{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}], + ) + pipes = load_pipe_notes(tmp_path) + design = {"pipe_length_m": 22, "paved": False} + seeded = with_culvert_note(design, 1433.0, pipes, "NO.71+13") + assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜 + labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]] + assert labels[2:] == [ + "배수관", + "Φ1200m/m", + "L=22.0m", + "NO.71+13", + "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", + "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", + "관유출부보강(돌붙임,찰) 10m2", + ] + cleared = {**design, "structure_note_cad": {"entities": []}} + assert with_culvert_note(cleared, 1433.0, pipes, "x") is cleared + old = {**design, "structure_note": "옛 글"} + assert with_culvert_note(old, 1433.0, pipes, "x") is old + assert with_culvert_note(None, 1433.0, pipes, "x") is None + assert with_culvert_note(design, 1440.0, pipes, "x") is design + + +def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path): + _write_pipes( + tmp_path, + [{"chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}], + ) + design = with_culvert_note({"pipe_length_m": 22}, 20.0, load_pipe_notes(tmp_path), "NO.1") + drawn = _draw(design) + labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"] + assert "NO.1" in labels and "관유출부보강(돌붙임,찰) 10m2" in labels + plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"] + drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"] + assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15 From 9ae5ee1439c7b7b0297e4a562945675ead2517df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:18:47 +0900 Subject: [PATCH 052/151] =?UTF-8?q?chore(plan):=20253~260=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80(=EC=9A=94=EA=B5=AC=20162=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=EB=81=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-11_plan_253-260.md | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/raw/plans/2026-10-11_plan_253-260.md diff --git a/docs/raw/plans/2026-10-11_plan_253-260.md b/docs/raw/plans/2026-10-11_plan_253-260.md new file mode 100644 index 000000000..23ee83b6b --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_253-260.md @@ -0,0 +1,57 @@ +# 2026-10-11 이관 — 253 ~ 260 (요구 162 묶음) + +## 253. 거리이정표 → 설계내역서 운반공 · 운반 단가 모달 (sub2 · 요구 162-1) +정함(브레인) = `docs/raw/요구_1010_2/조사_AJ_162묶음.md` 1장 · 사용자 확정 = 행 하나 = 운반공 줄 하나 · 연결 · 미연결 같은 모달 +- [x] 253-1 운반공 줄 = 거리이정표 행 1:1(자동 상자 옛 키 · 사용자 상자 `haul:r-{id}`) · 서버 B09 가 경로 전부 읽음 — 시험 · 검증완료(test_253_1 3건 · 사용자 상자 둘째 행 `haul:r-{id}.1` · 중점 행 L2 2.8) +- [x] 253-2 사이클시간 짝(Py · TS · 건설품셈 8-2-8 · 거창 단가산출 1308~1347 재현) + 로직 입력 이름 채움(Cm · 사이클시간 · t2 · 운반거리) · 서버 B09 셈 때 거리로 다시 채움 — 시험 · 검증완료(test_253_2 5건 · 거창 칸 읽어 t1 5.33 · t2 101.23 · Cm 109.78 · TS 거울 4벌 같음 · L1 10 이면 Cm 45.69) +- [x] 253-3 미리보기 길 `POST …/estimation/logic-preview`(render_sangeun_text) + 「운반 단가」 모달(단가산출 · [변경] · 옵션 값 · 계산 절차) — 거리이정표 우클릭 · 설계내역서 운반공 줄에서 엶 — 시험 · typecheck · 검증완료(test_253_3 4건 · GF000134 Cm 109.78 → 16,290원/㎥ · typecheck 새 빨강 없음) + +## 254. 거리이정표 여러 기준점 · 다이어그램 (sub8 · 요구 162-4 · 5 · 6 · 8 · 10) +정함(브레인) = 조사 AJ 2장 · 사용자 확정 = 기준점 2개 이상 · 기준점에도 선 연결 +- [x] 254-1 데이터 `bases[]` · 경로 `to` · 옛 `base` 옮김 · 돌기 막음 · 도로 구분(시점에 닿는 기준점 구간 1차 · 그 밖 2차) · Calc 짝 · 서버 모델 — 시험(먼저 커밋 · 보고) + - 검증완료 — test_254_1 5(옛 꼴 옮김 Py · TS · 서버 읽기 · PUT 새 꼴 · 기준점 둘 사슬 거래처 28.3 · 0.6 · 0.3 = 29.2 거울 · 안 이음 멈춤 · 돌기 A↔B 끊음 · makesLoop) + 244_* · 250_1 · 251_1 포함 84 통과 · typecheck · ruff +- [x] 254-2 다이어그램 — 기준점 상자 연결 점 · 기준점 주소 · 연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄(도로 / km) · 선 길이 거리 비례(로그 · 최소 · 최대) — 시험 + - 검증완료 — test_254_2 4(선 길이 = 100 + 90·log10(1 + km) · 최소 100 · 최대 320 · 테두리 사이 실측 · 주소 2줄 = 높이 73 · 3줄 넘으면 줄임표 · 선 글 두 줄 높이 34 · 연결 점 (10, 10) r 6 · 돌기 취소) + 244_* · 250_1 · 251_1 · 254_1 포함 88 통과 · typecheck · prettier +- [x] 254-3 상자 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」 · 장소 그림(`place_images`) 상자 안 배치(256-1 꼴) — 시험 · typecheck + - 검증완료 — test_254_3 2(그림 132 × 88 · 글 아래 안쪽 여백 8 · 상자 높이 + 96 · 그림 없는 상자 높이 그대로 · 제목 「거래처 —」 · 키 바뀔 때만 다시 받음) + 244_* · 250_1 · 251_1 · 254_* 포함 90 통과 · typecheck · prettier + +## 255. 거리이정표 시점 모달 · 거래처/기준점 모달 · 경로 탭 (sub1 · 요구 162-2 · 3 · 4) +정함(브레인) = 조사 AJ 2장 +- [x] 255-1 시점 모달 — 중점 측점 km · 설명 줄 지움 · 지도 = 공용 카카오 지도(시점 · 중점 표지) — 시험 · 검증완료(test_255_1 · 1.2345 km → 1234.5 m · 빈칸 = null · 244-4 · 244-6 기대값 고침 12 통과) +- [x] 255-2 (254-1 합친 뒤) 경로 탭 — 시점도 끌기(`site.start_pin` · 길찾기용) · 기준점 옮기면 그 기준점에 닿는 구간 모두 다시 · 다시 열면 모든 구간 선 · 실패 안내 — 시험 · 검증완료(test_255_2 · 덮개 없음 → 127,35 · 덮개 → 128.25,36.5 · 기준점 b1 = b1 · b2 · r2 다시(안 이은 r4 빠짐) · 거리이정표 시험 86 통과) +- [x] 255-3 (254-1 합친 뒤) 거래처 · 기준점 모달 — 제목 ≥ 내용 글 · 「소재지 — 주소 찾기 · 지도에서 고르기(…)」 지움 · 기준점 이름 한 줄 · 주소 · 지도 위 가로선 — typecheck · 검증완료(typecheck 0 · test_255_3 · 칸 이름 12 → 14px(입력 글과 같음) · 장소 그림 capturePlace = 등록 · 좌표 바뀜 · 경로 탭 끌기 · 거리이정표 시험 94 통과) + +## 256. 거리이정표 그림 영구 저장 (sub4 · 요구 162-6 · 7) +정함(브레인) = 조사 AJ 2장 · 사용자 확정 = 그림은 뒤에 다른 곳에서 씀 · 잘 보관 · 경로 그림은 화면 기능 변경 아님 +- [x] 256-1 장소 그림 — 거래처 · 기준점 좌표가 정해지면 서버가 작은 지도 캡처 → `milestone/place/.png` · GET 에 `place_images` · 지운 상자 그림은 [저장] 때 지움 — 시험 + - 검증완료(test_256_1 6건 · 실제 캡처 검증_sub4 place 2장 480×320 · 약 0.13MB) +- [x] 256-2 경로 그림 — 경로 탭과 같은 전체 경로(기준점 사슬 포함 · 254-1 뒤) → `milestone/auto/<경로 id>.png` · 좌표 · 구간이 바뀐 경로는 [저장] 때 다시 캡처 — 시험 + - 검증완료(test_256_2 4건 · 실제 캡처 auto/r1.png 1200×800 · 0.97MB · 도로 구간 길 선 · 길찾기 실패 구간 곧은 선 · 다시 [저장] 0.02초) + +## 257. B09 탭 · 표 (sub3 · 요구 162-11 · 12 · 13 · 15 · 16 · 17 · 18) +정함(브레인) = 조사 AJ 3장 +- [x] 257-1 위 탭 「설계내역」(총괄설계내역서 · 설계내역서) · 단가연결 칸 = 램프(누름 그대로 · 풍선) · 머리 「단가↵연결」 · 폭 줄임 — 시험 + - 검증완료(test_257_1 둘 · test_181_1 · test_229_1 · B09 UI 시험 167 통과 · 위 탭 8 · 램프 칸 class 셋 · 머리 폭 44) +- [x] 257-2 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 지움(Py · TS 짝) · 총공사비 = 아래 고정 줄(총괄 · 원가계산서) — 시험 + - 검증완료(test_257_2 셋 · test_185_1 · test_145_3 · 145_4 · 182 · 242_1 · 200_2 · B09 시험 248 통과 · 145_5 1건은 경비 집계표 안내 글 = 이 절 밖 · typecheck 새 오류 0) +- [x] 257-3 자재단가대비표 적용 단가 = 초록 테두리 · 우클릭 「적용 단가 바꾸기」 — 시험 · typecheck + - 검증완료(test_257_3 둘 · test_183_3 · B09 시험 248 통과 · typecheck B09 오류 0 — B08 Milestone_Modal 1건은 이 절 밖) + +## 258. 표 양식 · 구조물 조합 색 (sub5 · 요구 162-9 · 14 · 22) +정함(브레인) = 조사 AJ 4장 · 사용자 확정 = 입력 칸 테두리 옅게 되살림 +- [x] 258-1 템플릿 표 폭 = CSS 변수 · 거리이정표 표 100% — 시험 · 검증완료(test_258_1 · test_ui_data_table 통과 · 인라인 --dt-w · css width var · 고정 열 표 폭 값 그대로) +- [x] 258-2 입력 칸 테두리(템플릿 · outline · 옅게) · 시험 셋 기대값 고침 — 시험 · 검증완료(accent 28% outline · test_243_1 · 246_1 · 241_1 통과) +- [x] 258-3 B05/B06 구조물 조합 미지정 = 회색(구조물리스트 램프 색) — typecheck · 검증완료(text-muted 테두리 · 12% 바탕 · test_177_1 기대값 고침 통과) + +## 259. B06 횡단 상세 — 영역 · 길이 · 표 강조 (sub7 · 요구 162-19 · 20) +정함(브레인) = 조사 AJ 5장 +- [x] 259-1 공용 팬 `moved` 고침 · 암 경계선 나눔 · 고름(서버 · 스키마 다섯 곳) — 시험 · 검증완료(test_259 5건 · 팬 뒤 누름 통과 · 암 경계선 셋으로 나뉨 · 가운데 5.33m 서버 거울 · 209·164·179·150·244 47건) +- [x] 259-2 닫힌 영역 안 누름 고름 · 채움 · 면적 · 둘레 / 선 길이 · 기울기 · 표 칸 누름 = 그래프 고름 + 같은 정보 — 시험 · 검증완료(test_259 11건 · 면 둘 6.00㎡ · 4.67㎡ 찾음 · 열린 영역 0 · 붙은 둘 = 10.67㎡ 서버 거울 · 표 칸 → 저장 조각 · 띠 짝) +- [x] 259-3 표 강조 바깥 한 줄 · 겹선 없음 · 다른 묶음 안 물듦 — typecheck · 검증완료(ㄴ 모양 바깥 줄 10토막 · 칸 사이 겹선 없음 · tr:has 물들임 없앰 · typecheck 내 파일 0건) + +## 260. B06 상세 → B07 횡단배수 CAD 칸 (sub6 · 요구 162-21) +정함(브레인) = 조사 AJ 5장 · 사용자 확정 = 빈 CAD(바탕만) · 구조물 없으면 빈값 · 횡단배수만 미리 넣음 · B07 표 위 +- [x] 260-1 새 칸 `structure_note_cad`(스키마 · 남김 · 새로 셈 · 저장 patch) · 옛 글 옮김 — 시험 · 검증완료(test_260_note_cad 칸 4건 · test_138 7건 통과 · typecheck 내 파일 0) +- [x] 260-2 상세 모달 글 상자 → 빈 CAD 도면(cad_host · 높이 고정 · 닫으면 destroy) · 횡단배수 미리 넣음 스크립트(주인 측점 · 비었을 때만) — 시험 · 검증완료(test_260_note_cad 미리 넣음 10건 — 그림 7줄 일치 · 빈 구조물 · 세월교 · 숨은 관 · 이웃 = 빈값) +- [x] 260-3 B07 = CAD 칸 엔티티를 표 위(좌상 맞춤 · 범위 크기) · 없으면 옛 글 줄 — 시험 · 검증완료(test_260_note_cad B07 5건 — 범위 왼쪽 = 표 왼쪽 · 위 = 옛 표 윗변 · 표 15 mm 내림 = 범위 높이 · 배율 2 = 30 mm · test_150 · 174 · 180 통과) +- [x] 260-4 안 연 측점 = B07 이 파이썬 짝 스크립트로 넣음(TS 와 거울) · 지운 것([]) 유지 — 시험 · 검증완료(test_260_note_cad 거울 9쌍 TS = 파이썬 · 칸 없음만 넣음 · [] · 옛 글 · 이웃 그대로 · B07 표 내림 = 범위 높이 · B07 시험 108 통과) From 6e53acfcac471ff995860f44e197397d89670af1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:22:19 +0900 Subject: [PATCH 053/151] =?UTF-8?q?fix(b09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=95=88=EB=82=B4=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EC=A3=BC=EC=84=9D=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=B9=B8=20=EB=9E=A8=ED=94=84=20=EA=BC=B4?= =?UTF-8?q?=EB=A1=9C=20(257=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= 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_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 3bb3ec7be..1c37d2653 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -8,7 +8,7 @@ * 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값 * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` - * 「단가연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 + * 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴 * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 * ========================================================================== */ @@ -66,7 +66,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「단가연결 ▾」로 일위대가 · 단가산출 연결" + + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" + (direct.blank ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` : ""), From c8e861e90ae26ddf77cd6af4cb5b63107d79ce0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:12:55 +0900 Subject: [PATCH 054/151] =?UTF-8?q?chore(plan):=2010-11=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20163(=ED=99=94=EB=A9=B4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=9D=A0=20=EB=8B=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 1e942196d..e47c8d39a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -191,3 +191,4 @@ 160. 추가로 단가연결을 할때는 구조물조합이나 일위대가 사전조합이라면 문제가 없겠지만 단가산출의 경우에는 연결할때 옵션값을 받아야하는 경우가 있을거야. 이때는 추가 모달창을 보여줘서 사용자가 선택할수 있게 반영해줘. 만약 내가 잘못알고 있는거라면 알아서 해주고 161. (원문) 다음은 B09에 신규 메인탭인 공기를 넣어주고 하위에 공기집계와 공기산출 탭을 만들어줘. 이때 공기관련내용은 초안이니 B09 폴더 내부에 draft폴더를 만들어서 코드를 작업해줘. 나중에 마음에 안들면 그냥 draft폴더를 삭제할거야. 일단 참고문서는 resources\knowledge\original\실무문서\01.산불거창(거창북상병곡)\01.내역서\00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx 파일 중에 공기집계와 공기산출 탭을 참고하면 될거야. 여기서 중요한 점은 참고를 보고 내용을 넣는게 중요한게 아니라 로직을 분석하고 어떻게 계산하는지 확인하고 구현하는게 중요한거야. 다시말하지만 참고 항목이 있다고 테이블을 만드는게 아니야. 우리 프로그램에 맞춰 적용하는게 중요한거야. 그래서 길게 생각하고 작업해도 되니까. 꼼꼼히 분석해서 반영해줘. 이건 현재 작업내용을 이어서 진행하면 됨. 나 쉬러 갈테니까 작업완료하고 내일 아침에 내가 아티팩트를 만들어줘. 일단 이번 구현에 대해서는 방향성 결정이 필요한 내용이 있다면 너가 결정해서 아티팩트로 알려줘. 162. (원문 · 10-11) 1. 거리이정표 - 설계내역서 운반공 연결 - 거리이정표의 항목행 하나당 하나씩 설계내역서의 운반공 하위의 노용으로 들어가야함. - 거리이정표와 설계내역서의 연결 후 선택된 항목에 대해 거리값을 입력받아야하는 경우 연결되거나 될 거리이정표의 거리와 연결되어야 하며 이때 아마 비포장과 포장이 다를것같은데 이부분에 대한 계산 절차를 보여주는 모달창이 있어야함. 이때 모달창에는 옵션값과 계산 절차를 같은 화면에서 보여주며 단가산출을 다른 항목으로 수정할수 있어야함. 단가연결이 안된경우와 된경우를 같은 모달창을 사용하면 좋을것같아. 2. 거리이정표의 하위탭중 항목별 경로는 나타내는 하위탭에서 거래처/기준점/시점을 옮긴다면 주로를 옮겨서 업데이트 3. 거리이정표의 시점에서 나오는 모달창의 중점 측점은 km 단위로 변경, 하위의 설명은 삭제, 맵은 공통 카카오맵으로 변경 4. 거리이정표의 다이어그램에서 항목의 폰트를 거래처의 폰트와 동일하게 변경, 아래 주소가 길경우 자동으로 줄변경하고 컨테이너 세로 높이 증가, 방위 배치나 기본 맵연결에 따라 위치를 잡을때 거리에 따른 선길이가 지정되면 좋겠음. 단 너무 널리떠러지면 안됨. 선, 위의 텍스트 중 포장타입과 거리는 2행 구조로 변경, 거래처 및 기준점에 대한 모달창에서 제목의 폰트사이지가 내용보다 작음, 소재지-주소찯기.지도에서 고르기(...) 항목은 삭제, 기준점의 모달창인 경유 기준점의 이름이 2행구조로 됨. , 주소 및 맵 영역은 상단에 가로선을 추가하여 구분할것. 5. 기준점의 다이어그램 컨테이너에도 주소 추가 6. 거리이정표의 다이어그램에서 선연결의 드래그하는 원을 박스의 좌측 상단에 배치, 단 테두리를 넘어가면 안됨. 추가로 컨테이너의 내부에 작은 주소를 찾은 그림을 캡쳐하여 프로젝트 영구저장소에 넣어주고 컨테이너 내부에 배치 이때 그림을 다른 곳에서도 사용할 예정임. 잘 보관할 것. 7. 거리이정표의 하위 탭의 경로보기 기능 중 그림을 캡쳐하여 프로젝트 영구저장소에 넣어줄것. 프론트엔드 기능변경이 아님. 그림은 나중에 다른곳에 사용할 예정 8. 거리이정표의 다이어그램을 누렀을때나오는 제목에 테이블 제목 행의 정보가 들어오는데 삭제. 9. 거리이정표의 테이블이 컨테이너가 다이어그램의 영역과 동일하게 브라우져의 폭에 맞춰 변경이 되지만 테이블은 맞춤이 안됨. 혹시 테이블 양식과 충돌이 있다면 알려줄것. 10. 거리이정표의 기준점은 2개 이상일 수도 있을것같음. 이럴경우를 대비하여 선연결 기능을 기준점에도 추가 11. B09 총괄설계내역서와 설계내역서를 하위탭으로 이동하고 상위 탭으로 설계내역 탭을 생성 12. B09 단가연결 열의 정보가 텍스트로 구현되어있음. 랩프로 변경(램프양식은 B08의 구조물집계표에 있음), 선택 기능은 유지, 단가연결 열 제목은 단가 > 줄바꿈 > 연결 로 변경하고 테이블 열 폭을 줄일것. 13. B09와 테이블 행선택에서 페이지이동 기능이 구현된 행의 경우 붉은색 박스 하이라이트가 반영안된게 있음. 14. 하이라이트 기능과 연결하여 조심할 부분은 셀에 수기 입력이 가능한 영역을 셀테두리로 표기한게 없어졌음. 조심 테두리의 값을 조금 더 옅게 표현. 15. 자재단가대비표에 선택한 적용단가인 경우에 해당 셀에 색상을 변경했는데 이걸 테두리를 넣는걸로 대체. 이때 입력셀 테두리와 혼돈이 될수 있으니 녹색계열로 변경 16. 자재단가대비표에서 최저가 일지라도 적용단가를 변경해야할 경우가 있음. 마우스 우클릭에 항목을 적용단가 적용 항목 변경 17. 설계내역서의 비고 열에 빈 칸XX 정보는 삭제. 18. 총괄설계내역서/공사원가계산서의 제일하단의 총공사비는 하단고정으로 변경. 양식은 테이블 양식 확인 19. B06의 횡단도에서 상세버튼 모당창의 내용 중 나눠진 면적지정에 대한 기능 구현안됨. 기준은 현재 선들이 교차가 되면 선 요소를 분할하였음. (암경계선도 분할 필요) 이렇게 됨면 오픈된 영역을 제외하면 먼저 지정도 가능해야함. 이때 선택된 면적은 선을 지정하였을때 길이관련 정보(기능 누락됨)보여주는 정보들처럼 면적 정보들을 보여주면 됨.(이때 테이블을 선택하면 그래프에서 선이나 면적을 지정하게 될텐데 이때도 길이나 면적이 보여야함. ) 20. 횡단도 상세버튼 모달창 테이블의 항목들을 선택하면 테이블 하이라이트가 되는데 여기서 셀과 셀 제목을 지정해주는건 좋은데 중복되는 선때문에 보기가 안좋음. 21. 횡단도 상세버튼 모달창에서의 그래프 영역하단은 텍스트 입력창으로 별도의 정보를 B07로 전달되어야하는데 내가 원하는 방향은(그림_162-21_횡단배수_글.png — 배수관 원 표기 · NO.71+13 · 관보호공 · 관유입부보강 글) 구조물이 없는 경우에는 당연히 빈값이어야하고 구조물 중 횡단배수 구조물에 대해서만 미리 넣는 스크립트를 만들어주면 됨. 이때 이질감이 있을수 있으니 텍스트창이아니라 캐드창을 보여주는데 그냥 배경만 있는 캐드창이기만 하면됨. 그럼 다음B07에 테이블 상단에 배치하면 이질감이 없을것같아. 기본적으로 설계자들은 대부분 캐드 단축키를 알고 있으니 빈화면의 캐드화면이라도 문제 없어보임. 22. B05/B06 공통 좌측 패널의 구조물 조합 지정 기능에서 미지정 항목에 대해 노란새 색상이 좋기는 한데 눈에 너무 띄는것같음. 이건 구조물리스트의 램프와 같은 색상으로 가는게 좋겠어. 회색이 될것같네. 23. 이제까지는 너가 직접 코드작업을 요청했지만 이제 폭넓은 부분에 코드작업을 해야하니 너가 조사작업을 하고 계획서를 작성하고 브레인으로써 다른워크트리에게 작업을 지시해줘. 그리고 완료시 너가 작업이 누락된게 없는지 확인해줘. 코더는 자기가 작업한 코드만 검증을 하고 화면 검증은 작업완료시 내가 진행할께. 너도 필요하다고 판단되면 브라우져 검증진행해도 됨. +163. (원문 · 브레인 화면 검증 흠 다섯에 대한 답) 고칠 흠중 1. 소수점 제어 확인 2. 보류 3. 제안대로 4. 제안대로 5. 운반공의 하위에는 단가연결이 연결이 되던안되던 일단 이정표의 항목명으로 운반공에 하위에 개별로 들어와야함. From f0f35c57497d8b305b533960d2d08fc4dea61ecb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:16:12 +0900 Subject: [PATCH 055/151] =?UTF-8?q?fix(b09):=20=EA=B3=B5=EC=82=AC=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=EB=B9=84=20=EC=9D=B4=EB=8F=99=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=9D=84=20=EC=B4=9D=EA=B4=84=20=EA=B0=80.=20?= =?UTF-8?q?=EC=88=9C=EA=B3=B5=EC=82=AC=EB=B9=84=EA=B3=84(direct)=EB=A1=9C?= =?UTF-8?q?=20(261-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이동 대상 키 material 이 총괄 줄에 없어 빨강 상자가 안 뜸 - 시험 추가 - 모든 원가 줄 이동 대상이 총괄 줄 키에 있는지 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../B09_Estimation_UI_Tabs_Total.ts | 2 +- .../test_261_2_b09_cost_link_targets.py | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_261_2_b09_cost_link_targets.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 1ea78b13f..e41a21e1b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -271,7 +271,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { /** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */ const COST_ROWS: Array<[string, string, string, string, string?]> = [ - ["direct_material", "순공사원가", "재료비", "직접재료비", "material"], + ["direct_material", "순공사원가", "재료비", "직접재료비", "direct"], ["indirect_material", "순공사원가", "재료비", "간접재료비"], ["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"], ["material", "순공사원가", "재료비", "소계", "direct"], diff --git a/resources/tester/test_261_2_b09_cost_link_targets.py b/resources/tester/test_261_2_b09_cost_link_targets.py new file mode 100644 index 000000000..7b6db0414 --- /dev/null +++ b/resources/tester/test_261_2_b09_cost_link_targets.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""261-2 B09 공사원가계산서 줄 이동 대상 — 모든 줄의 대상 키가 총괄 줄 키에 있음(빈 대상 = 이동 없음 허용). + +직접재료비 → 총괄 「가. 순공사비계」(direct) · 직접노무비 · 산출경비와 같은 자리. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} + +BODY = f""" +const user = {{ estimate: {{}} }}; +const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; +const T = buildTotal(ctx), C = buildCost(ctx); +console.log(JSON.stringify({{ + totalKeys: T.rows.map((r) => r.key), + cost: C.rows.map((r, i) => [r.key, C.link(i)]), +}})); +""" + + +def _run() -> dict: + names = ("Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total") + script = "".join( + IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text(encoding="utf-8")) for n in names + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=script + BODY, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_모든_원가_줄_이동_대상이_총괄_줄에_있음(): + got = _run() + keys = set(got["totalKeys"]) + missing = [k for k, link in got["cost"] if link and link["key"] not in keys] + assert not missing, missing + assert len(got["cost"]) > 20 + + +def test_직접재료비_이동_대상_direct(): + links = {k: link for k, link in _run()["cost"]} + assert links["direct_material"] == {"tab": "total", "key": "direct"} + assert links["direct_labor"] == links["expense"] == links["direct_material"] From 4737756502accd84845d9d34afaa696341e42a8c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:17:40 +0900 Subject: [PATCH 056/151] =?UTF-8?q?fix(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20km=20=EC=B9=B8=20=EB=81=9D=EC=88=98=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=E2=80=94=203=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=98=EC=98=AC=EB=A6=BC=20=C2=B7=20=EB=81=9D=200=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80=20=C2=B7=20=EC=95=88=20=EA=B3=A0=EC=B9=98?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=80=EC=9E=A5=20m=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20(261-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시점 창 · 구간 창 · 다이어그램 선 위 km 칸 · 자동 값 글 = 같은 도우미(fmtKm) - 안 고친 칸은 반올림 값으로 덮지 않음 - 시험 test_261_1 추가 · test_255_1 기대값만 고침 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../B08_Quantity_UI_Milestone_Diagram.ts | 11 +-- .../B08_Quantity_UI_Milestone_LegModal.ts | 29 +++++--- .../B08_Quantity_UI_Milestone_Modal.ts | 13 +++- .../test_255_1_b08_milestone_site_modal.py | 2 +- .../tester/test_261_1_b08_milestone_km_fmt.py | 71 +++++++++++++++++++ 5 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_261_1_b08_milestone_km_fmt.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index a6178f573..ef0fa6f26 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -39,7 +39,7 @@ import { type DiagramBox, } from "./B08_Quantity_Milestone_Layout"; import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal"; -import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; +import { fmtKm, openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { findSegmentKm, liveMilestone, @@ -87,11 +87,12 @@ function editLegAt( className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, }); - km.value = leg.km == null ? "" : String(leg.km); + km.value = leg.km == null ? "" : fmtKm(leg.km); + const shown = km.value; // 안 고치면 저장 값 그대로 · 반올림 값으로 안 덮음 km.setAttribute("aria-label", "km"); if (leg.auto_km != null) { - km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값 - km.title = `자동 값 ${leg.auto_km} km`; + km.placeholder = fmtKm(leg.auto_km); // 빈칸 = 자동 값 + km.title = `자동 값 ${fmtKm(leg.auto_km)} km`; } const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); box.dataset.b08MilestoneLegEdit = `${from}:${i}`; @@ -108,7 +109,7 @@ function editLegAt( const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null; const patch = { road: (road.select.value || null) as Road | null, - ...(km.value.trim() && value === null ? {} : { km: value }), + ...(km.value === shown || (km.value.trim() && value === null) ? {} : { km: value }), }; putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 089b7c485..881dd86ca 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -24,7 +24,7 @@ import { } from "./B08_Quantity_Milestone_Calc"; import { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; -import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; +import { fmtKm, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { findSegmentKm, @@ -47,7 +47,13 @@ export function legsEditor( kind: SegKind, ): { root: HTMLElement; value: () => Leg[] } { const list = el("div"); - const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; + const rows: Array<{ + road: HTMLSelectElement; + km: HTMLInputElement; + auto: number | null; + orig: number | null; // 안 고친 칸 = 원래 값 그대로(반올림 값으로 안 덮음) + shown: string; + }> = []; const add = (leg: Leg): void => { const road = createSelectField({ label: "도로 구분", @@ -56,13 +62,13 @@ export function legsEditor( }); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); road.select.value = roadOf(leg.road, kind); - dist.input.value = leg.km == null ? "" : String(leg.km); + dist.input.value = leg.km == null ? "" : fmtKm(leg.km); const auto = leg.auto_km ?? null; if (auto != null) { - dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 - dist.input.title = `자동 값 ${auto} km`; + dist.input.placeholder = fmtKm(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + dist.input.title = `자동 값 ${fmtKm(auto)} km`; } - const ref = { road: road.select, km: dist.input, auto }; + const ref = { road: road.select, km: dist.input, auto, orig: leg.km, shown: dist.input.value }; rows.push(ref); const drop = createButton({ label: "×", @@ -90,9 +96,9 @@ export function legsEditor( return { root: el("div", { children: [list, more] }), value: () => - rows.map(({ road, km: dist, auto }) => ({ + rows.map(({ road, km: dist, auto, orig, shown }) => ({ road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, - km: km(dist.value), + km: dist.value === shown ? orig : km(dist.value), auto_km: auto, })), }; @@ -117,8 +123,9 @@ function openMidLeg(projectId: string): void { road.select.value = roadOf(data.site.mid_road, "mid"); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); dist.input.value = - data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); - dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 + data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000); + dist.input.placeholder = site?.mid_default_m == null ? "" : fmtKm(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 + const shown = dist.input.value; // 안 고치면 저장 값(m) 그대로 dist.input.dataset.b08MilestoneLegKm = "start"; const save = createButton({ label: "수정", @@ -129,7 +136,7 @@ function openMidLeg(projectId: string): void { projectId, setLeg(fresh, "start", 0, { road: road.select.value as Road, - km: km(dist.input.value), + ...(dist.input.value === shown ? {} : { km: km(dist.input.value) }), }), ); close(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 6d1bf33b5..0e3394a59 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -64,6 +64,9 @@ export function toastKm(text: string): void { if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); } +/** 칸 · 글에 보이는 km — 3자리(1 m) 반올림 · 끝 0 지움(셈 값은 안 바꿈 · 261-1) */ +export const fmtKm = (km: number): string => String(Math.round(km * 1000) / 1000); + let listSeq = 0; /** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */ @@ -270,9 +273,10 @@ export function openSiteInfo(projectId: string): void { compact: true, }); mid.input.step = "0.001"; - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m / 1000) : ""; + mid.input.placeholder = site?.mid_default_m != null ? fmtKm(site.mid_default_m / 1000) : ""; mid.input.value = - data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); + data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000); + const shown = mid.input.value; // 안 고치면 저장 값(m) 그대로 · 반올림 값으로 안 덮음 mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", @@ -290,7 +294,10 @@ export function openSiteInfo(projectId: string): void { const fresh = liveMilestone(projectId); putMilestone( projectId, - setLeg(fresh, "start", 0, { road: road.select.value as Road, km }), + setLeg(fresh, "start", 0, { + road: road.select.value as Road, + ...(text === shown ? {} : { km }), + }), ); close(); }, diff --git a/resources/tester/test_255_1_b08_milestone_site_modal.py b/resources/tester/test_255_1_b08_milestone_site_modal.py index e944a6d05..51ce9d5b0 100644 --- a/resources/tester/test_255_1_b08_milestone_site_modal.py +++ b/resources/tester/test_255_1_b08_milestone_site_modal.py @@ -41,7 +41,7 @@ def test_시점_모달_km_카카오_지도(): modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") site = modal[modal.index("export function openSiteInfo") :] assert 'label: "중점 측점 (km)"' in site - assert "String(data.site.mid_station_m / 1000)" in site # 보임 = km + assert "fmtKm(data.site.mid_station_m / 1000)" in site # 보임 = km(261-1 끝수 정리) assert 'setLeg(fresh, "start", 0, {' in site # 받음 km → 저장 m # 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음 for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"): diff --git a/resources/tester/test_261_1_b08_milestone_km_fmt.py b/resources/tester/test_261_1_b08_milestone_km_fmt.py new file mode 100644 index 000000000..85ef4acf0 --- /dev/null +++ b/resources/tester/test_261_1_b08_milestone_km_fmt.py @@ -0,0 +1,71 @@ +"""261-1 거리이정표 km 칸 끝수 — 보이는 km = 3자리(1 m) 반올림 · 끝 0 지움 · 안 고치면 저장 m 그대로 · +고치면 km × 1000 → m(끝수 없이).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +UI = "B08_Quantity/B08_Quantity_UI_Milestone" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(src, expr): + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _fmt(values): + modal = _read(f"{UI}_Modal.ts") + line = next(ln for ln in modal.splitlines() if ln.startswith("export const fmtKm")) + return _node(line.replace("export ", ""), f"{json.dumps(values)}.map(fmtKm)") + + +def test_km_글_3자리_끝0_지움(): + assert _fmt([576.595 / 1000, 0.3, 0.1 + 0.2, 12, 0, 1.2345, 0.0005, 2 / 3]) == [ + "0.577", "0.3", "0.3", "12", "0", "1.235", "0.001", "0.667", + ] # fmt: skip + + +def test_고치면_km_곱하기_1000_끝수_없이(): + layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + data = {"site": {"mid_station_m": 576.595, "mid_road": "비포장"}, "base": {}, "routes": []} + expr = f'setLeg({json.dumps(data)}, "start", 0, {{ km: 0.3 }}).site.mid_station_m' + assert _node(layout, expr) == 300 + # 안 고침 = km 키 없는 patch → 저장 m 그대로 + expr = f'setLeg({json.dumps(data)}, "start", 0, {{ road: "비포장" }}).site.mid_station_m' + assert _node(layout, expr) == 576.595 + + +def test_모든_km_칸_같은_규칙(): + modal = _read(f"{UI}_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert "fmtKm(data.site.mid_station_m / 1000)" in site + assert "fmtKm(site.mid_default_m / 1000)" in site + assert "...(text === shown ? {} : { km })" in site # 안 고침 = 덮지 않음 + + leg = _read(f"{UI}_LegModal.ts") + assert "fmtKm(data.site.mid_station_m / 1000)" in leg + assert "fmtKm(site.mid_default_m / 1000)" in leg + assert "...(dist.input.value === shown ? {} : { km: km(dist.input.value) })" in leg + assert "dist.value === shown ? orig : km(dist.value)" in leg # 구간 줄 + assert "fmtKm(leg.km)" in leg and "fmtKm(auto)" in leg + + diagram = _read(f"{UI}_Diagram.ts") + assert "fmtKm(leg.km)" in diagram and "fmtKm(leg.auto_km)" in diagram + assert "km.value === shown" in diagram # 선 위 km 고치기 칸 · 안 고침 = 덮지 않음 + for src in (modal, leg, diagram): + assert "String(leg.km)" not in src and "mid_station_m / 1000)" not in src.replace( + "fmtKm(data.site.mid_station_m / 1000)", "" + ) # 맨 String 변환 없음 From b751f29b011fe5ba6eda6261fe954e894856d38f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:19:06 +0900 Subject: [PATCH 057/151] =?UTF-8?q?fix(b09):=20=EC=9A=B4=EB=B0=98=EA=B3=B5?= =?UTF-8?q?=20=EC=A4=84=20=EC=9D=B4=EB=A6=84=20=3D=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=ED=95=AD=EB=AA=A9=EB=AA=85=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=ED=96=89=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B0=9C=EB=B3=84=20=EC=A4=84=20(261-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「운반」 덧붙임 없앰 · 빈 항목명 = 거래처 · 그것도 없으면 묶음 이름 · 「운반」 - 자동 상자 둘째 행부터 `haul:{묶음}.n` 개별 줄(묶음 줄 바로 뒤 · 수량 빈칸) - 시험 기대값 고침(253_1 · 251_1 · 145_3) · 새 시험 test_261_4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_Haul.py | 16 ++--- B09_Estimation/B09_Estimation_Bill_Items.py | 69 ++++++++++----------- resources/tester/test_145_3_b09_bill.py | 4 +- resources/tester/test_251_1_haul_groups.py | 4 +- resources/tester/test_253_1_haul_rows.py | 10 +-- resources/tester/test_261_4_haul_names.py | 65 +++++++++++++++++++ 6 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 resources/tester/test_261_4_haul_names.py diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index 5a628cb43..baf49493b 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -131,16 +131,18 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict def route_bill_key(route: dict[str, Any], index: int = 0) -> str: """거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` · - 한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침).""" + 한 상자 둘째 행부터 `.{n}`(261-4 자동 상자도 · 경로 id · 묶음 키에 「.」 없음 — 안 겹침).""" + tail = f".{index}" if index else "" if route.get("haul"): - return bill_key(str(route["haul"])) - return f"haul:r-{route.get('id')}" + (f".{index}" if index else "") + return bill_key(str(route["haul"])) + tail + return f"haul:r-{route.get('id')}" + tail def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]: - """거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] · + """거리이정표 표 행 하나 = 운반공 줄 하나(253-1 · 261-4 단가연결 무관) — + [{key, name, ref, user, haul, L1, L2}] · name = 행 항목명 그대로(없으면 거래처 · 그것도 없으면 빈칸) · L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) · - user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀).""" + user = 사용자 상자 · haul = 자동 상자 묶음 키(첫 행은 총괄자재 묶음 줄 이름 · 거리).""" routes = {r.get("id"): r for r in data.get("routes") or []} out, seen = [], set() for row in table: @@ -149,13 +151,13 @@ def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[st if key in seen: continue seen.add(key) - item = str(row.get("item") or "").strip() out.append( { "key": key, - "name": f"{item}운반" if item else "운반", + "name": str(row.get("item") or "").strip() or str(route.get("name") or "").strip(), "ref": route.get("ref"), "user": not route.get("haul"), + "haul": route.get("haul"), "L1": row.get("paved"), "L2": row.get("unpaved"), } diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index 72ad89487..9282a4e92 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -111,48 +111,45 @@ def haul_items( routes: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + - 거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) · + 거리이정표 행(253-1 · `B08_Quantity_Haul.haul_rows` · 행 하나 = 줄 하나 · 둘째 행부터 수량 빈칸) · + 이름 = 거리이정표 항목명 그대로(261-4 · 「운반」 안 붙임 · 비면 거래처 · 그것도 없으면 묶음 이름 · 「운반」) · 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) · `dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None).""" - dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []} + rows = routes or [] + by_key = {r["key"]: r for r in rows} + extra: dict[str, list[dict[str, Any]]] = {} # 자동 상자 둘째 행부터 — 묶음 줄 바로 뒤 + for r in rows: + if r.get("haul") and r["key"] != bill_key(str(r["haul"])): + extra.setdefault(str(r["haul"]), []).append(r) + + def line(key: str, name: str, unit: str, qty: Any, note: str) -> dict[str, Any]: + ref = (refs or {}).get(key) + route = by_key.get(key) or {} + return { + "key": key, + "section": "3", + "name": name, + "spec": "", + "unit": unit, + "qty": qty, + "note": note, + "ref": ref, + "dist": {"L1": route["L1"], "L2": route["L2"]} if route else None, + "why": WHY_LOGIC if (ref or {}).get("tab") == "logic" else WHY_HAUL, + } + out = [] for g in haul_groups(materials): - qty = g["qty"] - ref = (refs or {}).get(bill_key(g["key"])) - logic = (ref or {}).get("tab") == "logic" + key = bill_key(g["key"]) + name = (by_key.get(key) or {}).get("name") or g["name"] out.append( - { - "key": bill_key(g["key"]), - "section": "3", - "name": f"{g['name']}운반", - "spec": "", - "unit": g["unit"], - "qty": qty, - "note": "B08 총괄자재" if qty is not None else "", - "ref": ref, - "dist": dists.get(bill_key(g["key"])), - "why": WHY_LOGIC if logic else WHY_HAUL, - } - ) - for r in routes or []: - if not r.get("user"): - continue - ref = (refs or {}).get(r["key"]) - logic = (ref or {}).get("tab") == "logic" - out.append( - { - "key": r["key"], - "section": "3", - "name": r["name"], - "spec": "", - "unit": "", - "qty": None, - "note": "B08 거리이정표", - "ref": ref, - "dist": dists.get(r["key"]), - "why": WHY_LOGIC if logic else WHY_HAUL, - } + line(key, name, g["unit"], g["qty"], "B08 총괄자재" if g["qty"] is not None else "") ) + for r in extra.get(g["key"], []): + out.append(line(r["key"], r.get("name") or g["name"], "", None, "B08 거리이정표")) + for r in rows: + if r.get("user"): + out.append(line(r["key"], r.get("name") or "운반", "", None, "B08 거리이정표")) return out diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index ec6ef4aad..1113830dd 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -190,8 +190,8 @@ def test_항목_짜기_B08_수량(): "key": "pipe:revet|돌쌓기(찰)|2.0", } haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")} - assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None, - "시멘트운반": None, "철근운반": 0.75} # fmt: skip + assert haul == {"중기": None, "기계": None, "모래": 16, "자갈": None, + "시멘트": None, "철근": 0.75} # fmt: skip 261-4 묶음 이름 그대로 def test_거울_TS_가_같은_값(): diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py index 3251d7a59..e7fc2efd1 100644 --- a/resources/tester/test_251_1_haul_groups.py +++ b/resources/tester/test_251_1_haul_groups.py @@ -44,8 +44,8 @@ def test_묶음_장비_옛_여섯_늘_수량_없는_새_묶음_없음(): def test_B09_운반_줄_옛_키_그대로_새_묶음_키(): items = {i["key"]: i for i in haul_items(_mats(ROWS))} assert [bill_key(k) for k in ("equip_heavy", "sand", "rebar")] == ["haul:1", "haul:3", "haul:6"] - assert items["haul:3"]["name"] == "모래운반" and items["haul:3"]["qty"] == 16 - assert items["haul:remicon"]["name"] == "레미콘운반" and items["haul:remicon"]["unit"] == "㎥" + assert items["haul:3"]["name"] == "모래" and items["haul:3"]["qty"] == 16 + assert items["haul:remicon"]["name"] == "레미콘" and items["haul:remicon"]["unit"] == "㎥" assert all(i["section"] == "3" for i in items.values()) assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items) diff --git a/resources/tester/test_253_1_haul_rows.py b/resources/tester/test_253_1_haul_rows.py index 514dcf8b4..574e91208 100644 --- a/resources/tester/test_253_1_haul_rows.py +++ b/resources/tester/test_253_1_haul_rows.py @@ -37,7 +37,7 @@ def test_rows_one_per_table_row(): assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"] assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False) assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == ( - "혼합골재운반", 10, 0.8) # fmt: skip + "혼합골재", 10, 0.8) # fmt: skip 261-4 항목명 그대로 assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함 assert got["haul:r-u1"]["ref"] == LOGIC @@ -47,14 +47,14 @@ def test_bill_items_user_rows(): extra = haul_user(rows) items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)} assert ( - "haul:3" in items and items["haul:3"]["name"] == "모래운반" - ) # 자동 = 총괄자재 묶음 그대로 + "haul:3" in items and items["haul:3"]["name"] == "모래" + ) # 자동 = 상자 항목명(261-4) user = items["haul:r-u1"] assert (user["section"], user["name"], user["qty"], user["ref"]) == ( "3", - "혼합골재운반", + "혼합골재", None, LOGIC, ) - assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC + assert items["haul:r-u1.1"]["name"] == "깬돌" and items["haul:r-u1.1"]["ref"] == LOGIC assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8} diff --git a/resources/tester/test_261_4_haul_names.py b/resources/tester/test_261_4_haul_names.py new file mode 100644 index 000000000..57163c791 --- /dev/null +++ b/resources/tester/test_261_4_haul_names.py @@ -0,0 +1,65 @@ +"""261-4 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B08_Quantity.B08_Quantity_Haul import haul_rows # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402 + +LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}} +DATA = { + "site": {"mid_station_m": 2000, "mid_road": "비포장"}, + "base": {"enabled": False, "legs": []}, + "routes": [ + # 자동 상자 — 항목명 고침 · 둘째 행(같은 이름) + {"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False, + "legs": [{"road": "포장", "km": 25.1}], + "rows": [{"item": "강모래", "dest": "start"}, {"item": "강모래", "dest": "mid"}]}, + # 자동 상자 — 항목명 빈칸 · 거래처 없음 = 묶음 이름 + {"id": "h-gravel", "name": "", "haul": "gravel", "via_base": False, + "legs": [], "rows": [{"item": "", "dest": "start"}]}, + # 사용자 상자 — 「운반」 이 든 항목명 · 같은 이름 두 행 · 빈 항목명(거래처) · 연결 없음 + {"id": "u1", "name": "석산", "via_base": False, "legs": [{"road": "포장", "km": 10}], + "rows": [{"item": "전석운반", "dest": "start"}, {"item": "전석운반", "dest": "start"}, + {"item": " ", "dest": "start"}]}, + # 사용자 상자 — 항목명 · 거래처 모두 빈칸 · 연결 있음 + {"id": "u2", "name": "", "ref": LOGIC, "via_base": False, "legs": [], + "rows": [{"item": "", "dest": "start"}]}, + ], +} # fmt: skip + + +def _items() -> dict[str, dict]: + rows = haul_rows(DATA, compute_milestone(DATA, None)) + return {i["key"]: i for i in haul_items(None, haul_user(rows)["haul_refs"], rows)} + + +def test_자동_상자_이름_항목명(): + items = _items() + assert items["haul:3"]["name"] == "강모래" + assert items["haul:4"]["name"] == "자갈" # 항목 · 거래처 빈칸 = 묶음 이름 + assert items["haul:1"]["name"] == "중기" # 상자 없음 = 묶음 이름 + + +def test_자동_상자_둘째_행_개별_줄_묶음_바로_뒤(): + items = _items() + keys = list(items) + assert keys.index("haul:3.1") == keys.index("haul:3") + 1 + extra = items["haul:3.1"] + assert (extra["name"], extra["qty"], extra["dist"]["L2"]) == ("강모래", None, 2) + + +def test_사용자_상자_이름_항목명_같은_이름_두_줄(): + items = _items() + assert items["haul:r-u1"]["name"] == items["haul:r-u1.1"]["name"] == "전석운반" + assert items["haul:r-u1.2"]["name"] == "석산" # 빈 항목명 = 거래처 + assert items["haul:r-u2"]["name"] == "운반" and items["haul:r-u2"]["ref"] == LOGIC + assert items["haul:r-u1"]["ref"] is None # 연결 없어도 줄 + + +def test_운반운반_없음(): + assert not any("운반운반" in i["name"] for i in _items().values()) From c55f614d50e2481bc2cb6c34daba9cb1af521103 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:36:55 +0900 Subject: [PATCH 058/151] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=E2=80=94=20=EB=B2=BD=20=C2=B7=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=C2=B7=20=EB=B3=B8=EC=B2=B4=20=EB=91=98?= =?UTF-8?q?=EB=A0=88=EB=A5=BC=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=84=A0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20(261-3=20=EA=B1=B8=EC=9D=8C=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계선 끝 ~ 깎기 비탈 밑을 집수정 둘레가 이어 깎기 암석(암선 ↔ 설계선) · 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘. 배수관 측점(검증_랩탑메인 4+15) 깎기 암석 영역 18.24㎡ 생김 · 일반 깎기 측점은 그대로(그림 칠 면적과 같음). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Cross_Design_Pick.ts | 2 +- B06_Section/B06_Section_UI_Cross_Detail_Select.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 36575f17f..2d8958dd7 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -33,7 +33,7 @@ const HIT_CLASS = "b06-chart__design-hit"; /** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ export const DESIGN_LINE_ATTR = "data-design-line"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ -const OCCLUDER_CLASSES = [ +export const OCCLUDER_CLASSES = [ "b06-chart__culvert-revet", "b06-chart__culvert-basin", "b06-chart__ford-slab", diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 98f4d5eba..057f8c09c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -6,6 +6,7 @@ * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. + * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘 · 261-3). * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. @@ -17,6 +18,7 @@ import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; import { DESIGN_LINE_ATTR, mergeStraightPieces, + OCCLUDER_CLASSES, pieceLabel, } from "./B06_Section_UI_Cross_Design_Pick"; import { @@ -84,6 +86,12 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli points: points.map(([offset, elevation]) => ({ offset, elevation })), structure: !el.hasAttribute(DESIGN_LINE_ATTR), }); + else if (OCCLUDER_CLASSES.some((name) => el.classList.contains(name))) + // 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리) + runs.push({ + points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })), + structure: true, + }); else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); From 2bf429ec98589a04cf1a75ac44702ba6fa40e7c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:37:19 +0900 Subject: [PATCH 059/151] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=20=ED=8C=90=EC=A0=95=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EB=B3=B4=ED=86=B5=20=EB=B0=B0=EC=9C=A8?= =?UTF-8?q?=20=EC=96=87=EC=9D=80=20=EB=9D=A0=20=3D=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=20=EA=B0=80=EA=B9=8C=EC=9D=B4=20=3D=20?= =?UTF-8?q?=EC=84=A0=20(261-3=20=EA=B1=B8=EC=9D=8C=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 누름 = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 선 4 px 안 = 선 · 영역 안이면 맞은편 선까지 ⅓ 안이어야 선. 선 누름이 표 띠 누름보다 앞섬(stopImmediatePropagation) · 판정 선 굵기 10 → 8 px(걸침 표시만). 시험 test_261_3_detail_pick(견본 · 실측 4+15 그림) · 233-2 헬퍼 누름 꼴 맞춤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B06_Section_UI_Cross_Detail_Select.ts | 133 +++++++--- .../B06_Section_UI_Cross_Detail_Table.ts | 7 +- .../B06_Section_UI_Style_Cross_Detail.css | 3 +- .../tester/fixtures/b06_261_3_cross_415.json | 1 + resources/tester/helper_233_2_view_pick.cjs | 23 +- resources/tester/helper_261_3_detail_pick.cjs | 85 +++++++ resources/tester/test_261_3_detail_pick.py | 237 ++++++++++++++++++ 7 files changed, 453 insertions(+), 36 deletions(-) create mode 100644 resources/tester/fixtures/b06_261_3_cross_415.json create mode 100644 resources/tester/helper_261_3_detail_pick.cjs create mode 100644 resources/tester/test_261_3_detail_pick.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 057f8c09c..f6f8f101e 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -6,7 +6,8 @@ * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. - * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘 · 261-3). + * 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 · + * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘). * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. @@ -49,12 +50,13 @@ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { }; } -function mToPx(axes: CrossAxesOptions): (p: Pt) => string { +/** m → 그림 px(`join` 하면 `x,y`). */ +function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt { return ([offset, elevation]) => { const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration; const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter; const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter; - return `${x},${y}`; + return [x, y]; }; } @@ -208,6 +210,67 @@ export interface DetailSelect { detach: () => void; } +/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */ +export const LINE_PICK_PX = 4; + +const segmentGap = (p: Pt, a: Pt, b: Pt): number => { + const d = [b[0] - a[0], b[1] - a[1]]; + const size2 = d[0] ** 2 + d[1] ** 2; + const t = size2 + ? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2)) + : 0; + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +const pathGap = (p: Pt, path: Pt[]): number => + Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q))); +const inside = (p: Pt, ring: Pt[]): boolean => + ring.reduce((inn, a, i) => { + const b = ring[(i + 1) % ring.length]; + return a[1] > p[1] !== b[1] > p[1] && + p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1]) + ? !inn + : inn; + }, false); + +/** + * 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이. + * 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다 + * 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역. + */ +export function pickAt( + point: Pt, + scale: number, + paths: Pt[][], + rings: Pt[][], + areas: number[], + pieces: SplitPiece[], +): { piece: number } | { face: number } | null { + const gaps = paths.map((path) => pathGap(point, path) * scale); + const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0); + const ends = (piece: SplitPiece): Pt[] => [ + piece.points[0], + piece.points[piece.points.length - 1], + ]; + const touches = (a: SplitPiece, b: SplitPiece): boolean => + ends(a).some((p) => + ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M), + ); + const across = Math.min( + Infinity, + ...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])), + ); + const face = rings + .flatMap((ring, i) => (inside(point, ring) ? [i] : [])) + .sort((a, b) => areas[a] - areas[b])[0]; + if ( + paths.length && + gaps[nearest] <= LINE_PICK_PX && + (face === undefined || gaps[nearest] * 2 <= across) + ) + return { piece: nearest }; + return face === undefined ? null : { face }; +} + /** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ const pieceKey = (piece: SplitPiece | undefined): string => piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; @@ -228,6 +291,10 @@ export function attachDetailSelect(input: { let hits: SVGPolylineElement[] = []; let faces: Face[] = []; let faceEls: SVGPolygonElement[] = []; + /** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */ + let layer: SVGGElement | null = null; + let paths: Pt[][] = []; + let rings: Pt[][] = []; let selected: number[] = []; /** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */ let chosenFaces: number[] = []; @@ -270,18 +337,25 @@ export function attachDetailSelect(input: { if (bar && label.parentElement !== bar) bar.prepend(label, put); }; + /** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */ + const pickOf = (event: MouseEvent): ReturnType => { + const ctm = layer?.getScreenCTM(); + if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null; + const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse()); + const areas = faces.map((face) => face.area); + return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces); + }; + const onClick = (event: MouseEvent): void => { - const target = event.target as Element; - const hit = target.closest?.(`.${PIECE_HIT_CLASS}`); + const pick = pickOf(event); const shift = event.shiftKey || event.ctrlKey || event.metaKey; const additive = shift || mode === "area"; noteText = null; - if (!hit) { - const face = target.closest?.(`.${FACE_HIT_CLASS}`); - if (face && !(mode === "area" && input.band?.(event))) { + if (!pick || !("piece" in pick)) { + if (pick && !(mode === "area" && input.band?.(event))) { // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) event.stopPropagation(); - const index = faceEls.indexOf(face as SVGPolygonElement); + const index = pick.face; chosenFaces = toggleSelection(chosenFaces, index, shift); selected = regionEdges(chosenFaces.map((i) => faces[i])); show(); @@ -292,9 +366,10 @@ export function attachDetailSelect(input: { show(); return; } - event.stopPropagation(); + // 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬 + event.stopImmediatePropagation(); chosenFaces = []; - selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); + selected = toggleSelection(selected, pick.piece, additive); show(); input.picked(additive); }; @@ -305,31 +380,29 @@ export function attachDetailSelect(input: { // 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2) const keep = new Set(selected.map((i) => pieceKey(pieces[i]))); const axes = input.axes(); - const layer = cad.querySelector("g.b06-chart__plot-layer"); + layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); chosenFaces = []; faces = findFaces(pieces); const toPx = axes ? mToPx(axes) : null; + paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : []; + rings = toPx ? faces.map((face) => face.ring.map(toPx)) : []; // 영역 판정 면 먼저(밑) → 조각 판정 선(위) - faceEls = toPx - ? faces.map((face) => { - const el = document.createElementNS(SVG_NS, "polygon"); - el.setAttribute("points", face.ring.map(toPx).join(" ")); - el.setAttribute("class", FACE_HIT_CLASS); - layer?.append(el); - return el; - }) - : []; - hits = toPx - ? pieces.map((piece) => { - const hit = document.createElementNS(SVG_NS, "polyline"); - hit.setAttribute("points", piece.points.map(toPx).join(" ")); - hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`); - layer?.append(hit); - return hit; - }) - : []; + faceEls = rings.map((ring) => { + const el = document.createElementNS(SVG_NS, "polygon"); + el.setAttribute("points", ring.join(" ")); + el.setAttribute("class", FACE_HIT_CLASS); + layer?.append(el); + return el; + }); + hits = paths.map((path, i) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute("points", path.join(" ")); + hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`); + layer?.append(hit); + return hit; + }); show(); }, setMode(kind) { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 65cc99823..366264f28 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -24,7 +24,7 @@ import type { import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; -import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; +import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -393,10 +393,9 @@ export function attachDetailTable(host: DetailTableHost): { document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음 + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정) const onPick = (event: MouseEvent): void => { - if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) - return; + if (picking?.kind !== "area") return; const hit = bandAt(event); const source = hit ? areaSourceOf(hit) : null; if (!source) return; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index be3ea6726..f9507d873 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -185,7 +185,8 @@ .b06-detail__piece { fill: none; stroke: transparent; - stroke-width: 10; + /* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */ + stroke-width: 8; stroke-linecap: round; vector-effect: non-scaling-stroke; pointer-events: stroke; diff --git a/resources/tester/fixtures/b06_261_3_cross_415.json b/resources/tester/fixtures/b06_261_3_cross_415.json new file mode 100644 index 000000000..3e3c74f6e --- /dev/null +++ b/resources/tester/fixtures/b06_261_3_cross_415.json @@ -0,0 +1 @@ +[{"tag":"polyline","cls":"b06-chart__cross-profile","dl":false,"pts":[[949.954642190761,98.34482758620575],[943.9057761359919,103.41302728071172],[937.856910081223,108.574113362354],[931.8080440264539,113.79092159809379],[925.7591779716848,116.3634075493297],[919.7103119169159,116.29158331379654],[913.6614458621468,119.40724533901843],[907.6125798073778,128.6081150062668],[901.5637137526088,136.825681007652],[895.5148476978397,140.7823168804701],[889.4659816430707,144.57612937682438],[883.4171155883017,145.92762751944147],[877.3682495335327,147.27912566205856],[871.3193834787636,149.41340756082334],[865.2705174239946,151.6286595805964],[859.2216513692256,160.15366524808218],[853.1727853144566,170.78189793600617],[847.1239192596876,176.7764088804081],[841.0750532049185,179.99066742232273],[835.0261871501496,182.80751546443977],[828.9773210953805,185.17395284238637],[822.9284550406114,188.64809486547938],[816.8795889858425,194.55915565378862],[810.8307229310734,201.03251903054885],[804.7818568763043,209.94251082695544],[798.7329908215354,218.7469378129739],[792.6841247667663,224.27866211325696],[786.6352587119974,229.81038641354002],[780.5863926572283,240.940372540707],[774.5375266024593,252.87010344444045],[768.4886605476903,262.9864810868107],[762.4397944929212,272.4983955443274],[756.3909284381522,278.89896686698546],[750.3420623833832,283.1707123680587],[744.2931963286142,286.96572253989297],[738.2443302738451,290.14777691893215],[732.1954642190761,293.9478439427878],[726.1465981643071,299.107502492042],[720.097732109538,304.0510471548911],[714.0488660547691,307.82759618752465],[708,311.6041452201596],[701.951133945231,315.9430573299059],[695.902267890462,320.2819694396523],[689.8534018356929,323.72677446237947],[683.804535780924,327.04386372722547],[677.7556697261549,330.0219261474343],[671.7068036713858,332.86733693506136],[665.6579376166169,335.10343334726014],[659.6090715618478,336.86563739726404],[653.5602055070788,338.91952986616036],[647.5113394523098,341.34845203045313],[641.4624733975407,343.8562151149041],[635.4136073427718,346.56547802537034],[629.3647412880027,349.78795092738864],[623.3158752332337,356.6029058679937],[617.2670091784647,363.4178608085988],[611.2181431236957,368.15345755421663],[605.1692770689266,372.88905429983583],[599.1204110141576,374.600193822605],[593.0715449593886,375.75124464189906],[587.0226789046195,376.6134500093462],[580.9738128498506,377.3443707879392],[574.9249467950815,381.8309962067428],[568.8760807403125,389.2387158252522],[562.8272146855435,396.575312876689],[556.7783486307744,403.8079178229121],[550.7294825760055,409.86913566216543],[544.6806165212364,412.4161316918462],[538.6317504664673,415.7644452032679],[532.5828844116984,424.7219689891325],[526.5340183569293,433.67948067726644],[520.4851523021603,436.17899310841756],[514.4362862473913,438.47018259264297],[508.38742019262224,445.02930679232855],[502.33855413785324,452.5733436562465],[496.28968808308423,456.2258788370261],[490.24082202831516,458.10954411740977],[484.19195597354616,460.61677490164794],[478.14308991877715,463.67421054222643],[472.0942238640081,468.8769491155226],[466.0453578092391,477.65517241379195]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[732.1954642190761,325.66273823867266],[726.1465981643071,325.57200524785054]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[726.1465981643071,325.57200524785054],[720.097732109538,325.48127225702837]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[720.097732109538,325.48127225702837],[714.0488660547691,325.3905392662076]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[714.0488660547691,325.3905392662076],[708,325.2998062753855]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[708,325.2998062753855],[701.951133945231,325.20907328456474]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[701.951133945231,325.20907328456474],[695.902267890462,325.11834029374256]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[695.902267890462,325.11834029374256],[689.8534018356929,325.0276073029218]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[689.8534018356929,325.0276073029218],[683.804535780924,324.9368743120997]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[683.804535780924,324.9368743120997],[674.1263500932936,333.0020290517912]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[674.1263500932936,333.0020290517912],[665.7789149377124,342.6802147394211]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[665.7789149377124,342.6802147394211],[641.2227165723032,343.9671505633862]]},{"tag":"polyline","cls":"b06-chart__rock-boundary","dl":false,"pts":[[949.954642190761,104.39369364097477],[943.9057761359919,109.46189333548074],[937.856910081223,114.62297941712302],[931.8080440264539,119.83978765286282],[925.7591779716848,122.41227360409871],[919.7103119169159,122.34044936856556],[913.6614458621468,125.45611139378745],[907.6125798073778,134.65698106103582],[901.5637137526088,142.87454706242102],[895.5148476978397,146.8311829352391],[889.4659816430707,150.62499543159342],[883.4171155883017,151.9764935742105],[877.3682495335327,153.3279917168276],[871.3193834787636,155.46227361559238],[865.2705174239946,157.67752563536544],[859.2216513692256,166.20253130285118],[853.1727853144566,176.83076399077518],[847.1239192596876,182.82527493517713],[841.0750532049185,186.03953347709177],[835.0261871501496,188.8563815192088],[828.9773210953805,191.22281889715538],[822.9284550406114,194.6969609202484],[816.8795889858425,200.60802170855763],[810.8307229310734,207.0813850853179],[804.7818568763043,215.99137688172448],[798.7329908215354,224.79580386774293],[792.6841247667663,230.327528168026],[786.6352587119974,235.85925246830902],[780.5863926572283,246.98923859547602],[774.5375266024593,258.9189694992094],[768.4886605476903,269.03534714157973],[762.4397944929212,278.5472615990965],[756.3909284381522,284.94783292175447],[750.3420623833832,289.21957842282774],[744.2931963286142,293.014588594662],[738.2443302738451,296.19664297370116],[732.1954642190761,299.9967099975568],[726.1465981643071,305.15636854681105],[720.097732109538,310.0999132096601],[714.0488660547691,313.87646224229366],[708,317.6530112749286],[701.951133945231,321.991923384675],[695.902267890462,326.3308354944213],[689.8534018356929,329.7756405171485],[683.804535780924,333.0927297819945],[677.7556697261549,336.07079220220334],[671.7068036713858,338.91620298983037],[665.6579376166169,341.1522994020292],[659.6090715618478,342.91450345203305],[653.5602055070788,344.96839592092937],[647.5113394523098,347.39731808522214],[641.4624733975407,349.90508116967317],[635.4136073427718,352.61434408013935],[629.3647412880027,355.83681698215764],[623.3158752332337,362.65177192276275],[617.2670091784647,369.4667268633678],[611.2181431236957,374.20232360898564],[605.1692770689266,378.93792035460484],[599.1204110141576,380.649059877374],[593.0715449593886,381.8001106966681],[587.0226789046195,382.6623160641152],[580.9738128498506,383.39323684270823],[574.9249467950815,387.8798622615118],[568.8760807403125,395.2875818800212],[562.8272146855435,402.624178931458],[556.7783486307744,409.8567838776811],[550.7294825760055,415.91800171693444],[544.6806165212364,418.46499774661527],[538.6317504664673,421.81331125803695],[532.5828844116984,430.7708350439015],[526.5340183569293,439.72834673203545],[520.4851523021603,442.22785916318657],[514.4362862473913,444.51904864741203],[508.38742019262224,451.07817284709756],[502.33855413785324,458.6222097110155],[496.28968808308423,462.27474489179514],[490.24082202831516,464.1584101721788],[484.19195597354616,466.66564095641695],[478.14308991877715,469.7230765969955],[472.0942238640081,474.9258151702916],[466.0453578092391,483.704038468561]]},{"tag":"polygon","cls":"b06-chart__culvert-basin","dl":false,"pts":[[736.913579741796,343.80933640297974],[732.1954642190761,325.66273823867266],[734.6150106409838,325.66273823867266],[739.3331261637036,343.80933640297974]]},{"tag":"polygon","cls":"b06-chart__culvert-basin","dl":false,"pts":[[738.7040440940075,341.3897899810716],[753.221322625453,341.3897899810716],[752.4954586988808,343.80933640297974],[739.3331261637036,343.80933640297974]]},{"tag":"polyline","cls":"b06-chart__design-cross","dl":false,"pts":[[753.221322625453,341.3897899810716],[801.4280127212174,220.8730647416608],[843.7112234665693,178.58985399634633]]},{"tag":"polyline","cls":"b06-chart__design-cross","dl":false,"pts":[[665.7789149377124,342.6802147394211],[641.2227165723032,343.9671505633862]]},{"tag":"polygon","cls":"b06-chart__culvert-revet is-selectable","dl":false,"pts":[[676.8483398179395,348.72908079419017],[676.8483398179395,333.0020290517912],[668.6823706440014,333.0020290517912],[663.9642551212817,348.72908079419017]]}] diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index 0158756a5..f4c8623ca 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -275,6 +275,17 @@ const layerChildren = []; const layer = { querySelectorAll: () => [...layerChildren], append: (hit) => layerChildren.push(hit), + // 261-3 누름 = 거리 판정 — 화면 좌표 = 그림 px(배율 1) + getScreenCTM: () => ({ a: 1, b: 0, inverse: () => null }), + ownerSVGElement: { contains: () => true }, +}; +globalThis.DOMPoint = class { + constructor(x, y) { + [this.x, this.y] = [x, y]; + } + matrixTransform() { + return this; + } }; const drawGround = () => { layerChildren.length = 0; @@ -302,7 +313,17 @@ const select = DTS.attachDetailSelect({ drawGround(); select.redraw(); const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS)); -click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} }); +const [clientX, clientY] = hit.getAttribute("points").split(" ")[0].split(",").map(Number); +click({ + target: hit, + clientX, + clientY, + shiftKey: false, + ctrlKey: false, + metaKey: false, + stopPropagation() {}, + stopImmediatePropagation() {}, +}); out.detailPicked = select.source("length")?.pieces.length ?? 0; axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림 drawGround(); diff --git a/resources/tester/helper_261_3_detail_pick.cjs b/resources/tester/helper_261_3_detail_pick.cjs new file mode 100644 index 000000000..5e913780e --- /dev/null +++ b/resources/tester/helper_261_3_detail_pick.cjs @@ -0,0 +1,85 @@ +/* PLAN 261-3 단위검증 헬퍼 — [상세] 누름 판정(거리 · 얇은 띠) · 구조물 둘레로 닫히는 깎기 영역. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON + * {cases: [{name, axes, elements: [{tag, cls, dl, pts(그림 px)}], clicks: [[x, y, scale]]}]} · + * 결과 = 표준출력 JSON {이름: {pieces, faces, clicks}}. 짝 = `test_261_3_detail_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); + +const element = (e) => ({ + tagName: e.tag, + classList: { contains: (name) => e.cls.split(/\s+/).includes(name) }, + hasAttribute: (name) => name === "data-design-line" && e.dl, + getAttribute: (name) => { + if (name === "points") return e.pts.map((p) => p.join(",")).join(" "); + const m = /^([xy])([12])$/.exec(name); + return m ? String(e.pts[+m[2] - 1][m[1] === "x" ? 0 : 1]) : null; + }, +}); + +const { cases } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; +for (const { name, axes, elements, clicks } of cases) { + const pieces = S.collectPieces(elements.map(element), axes); + const faces = G.findFaces(pieces); + const toPx = ([offset, elevation]) => [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ]; + const paths = pieces.map((piece) => piece.points.map(toPx)); + const rings = faces.map((face) => face.ring.map(toPx)); + const areas = faces.map((face) => face.area); + out[name] = { + pieces: pieces.map((piece) => ({ line: piece.line, points: piece.points })), + faces: faces.map((face) => { + const edges = face.edges.map((i) => pieces[i]); + const source = S.selectionSource("area", edges); + return { + area: face.area, + edges: face.edges, + lines: [...new Set(edges.map((piece) => piece.line))].sort(), + ring: face.ring, + source, + }; + }), + clicks: (clicks ?? []).map(([x, y, scale]) => + S.pickAt([x, y], scale, paths, rings, areas, pieces), + ), + px: { paths, rings }, + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_261_3_detail_pick.py b/resources/tester/test_261_3_detail_pick.py new file mode 100644 index 000000000..91779b7ba --- /dev/null +++ b/resources/tester/test_261_3_detail_pick.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""PLAN 261-3 — B06 [상세] 영역 고르기: 보통 배율 얇은 띠 누름 · 깎기 토사 / 암석 영역 나눔. + +못박는 것 + - 누름 판정 = 거리(`pickAt`) — 얇은 띠(6 px) 가운데 = 영역 · 선 1 px = 선 · 영역 밖 선 가까이 = 선 · + 넓은 영역은 선 4 px 안 = 선 · 확대 · 축소 배율에서도 같은 판정. + - 벽 · 집수정 둘레 = 구조물 선 — 설계선 끝 ~ 깎기 비탈 밑을 이어 깎기 암석(암선 ↔ 설계선) · + 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘 · 면적 = 사다리꼴 셈 · 서버 거울. + - 실측 그림(검증_랩탑메인 4+15 배수관 · 암 · 깎기 비탈) — 깎기 암석 영역이 생김 · 보통 배율 띠 누름 = 영역. + +./venv/Scripts/python.exe -m pytest resources/tester/test_261_3_detail_pick.py -q +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +PAD = { + k: float(v) + for k, v in re.findall( + r"(\w+): (\d+)", + re.search( + r"export const CROSS_PAD = \{([^}]*)\}", + (ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(encoding="utf-8"), + ).group(1), + ) +} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +# ── 견본: 암 지반(암선 = 지반 − 0.6 m · 보통 배율 6 px 띠) + 오른쪽 설계 비탈 + 왼쪽 집수정 · 깎기 비탈 ── +AXES = { + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 106, + "elevationMid": 100, + "exaggeration": 1, +} +ROCK = -0.6 +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": ROCK, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in [(6, 104), (2, 98), (-2, 98)]], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + + +def px(offset: float, elevation: float) -> list[float]: + return [ + PAD["left"] + (AXES["maxOffset"] - offset) * AXES["pixelsPerMeter"], + PAD["top"] + (AXES["displayMax"] - elevation) * AXES["pixelsPerMeter"], + ] + + +def el(tag: str, cls: str, pts, dl: bool = False) -> dict: + return {"tag": tag, "cls": cls, "dl": dl, "pts": [px(*p) for p in pts]} + + +GROUND = [(x, 100.0) for x in (-10, -5, 0, 5, 10)] +ELEMENTS = [ + el("polyline", "b06-chart__cross-profile", GROUND), + el("polyline", "b06-chart__rock-boundary", [(x, y + ROCK) for x, y in GROUND]), + el("line", "b06-chart__design-cross", [(6, 104), (2, 98)], dl=True), + el("line", "b06-chart__design-cross", [(2, 98), (-2, 98)], dl=True), + el("polygon", "b06-chart__culvert-basin", [(-2, 98), (-2, 97), (-3, 97), (-3, 98)]), + el("polyline", "b06-chart__design-cross", [(-3, 98), (-4, 100)]), # 집수정 절토선(구조물) +] +RIGHT_ROCK = 2 + 1.4 * 4 / 6 # 설계 비탈(4 : 6) × 암선 99.4 +RIGHT_GROUND = 2 + 2 * 4 / 6 # 설계 비탈 × 지반 100 +LEFT_ROCK = -3 - 1.4 / 2 # 깎기 비탈(1 : 0.5) × 암선 +LEFT_GROUND = -4.0 +CUT_ROCK = ((RIGHT_ROCK - LEFT_ROCK) + 5) / 2 * 1.4 # 암선 ↔ 노면 · 집수정 윗변(-3 ~ 2) +CUT_SOIL = ((RIGHT_GROUND - LEFT_GROUND) + (RIGHT_ROCK - LEFT_ROCK)) / 2 * 0.6 + +# 누름(그림 px · 배율) — 지반 y 70 · 암선 y 76 · 노면 y 90 +CLICKS = [ + [198, 73, 1], # 띠 가운데 = 영역(토사) + [198, 71, 1], # 지반선 1 px = 선 + [198, 75, 1], # 암선 1 px = 선 + [198, 79, 1], # 넓은 암석 영역 · 암선 3 px = 선 + [198, 81, 1], # 암선 5 px = 영역(암석) + [198, 67, 1], # 영역 밖 · 지반선 3 px = 선 + [198, 60, 1], # 영역 밖 · 먼 곳 = 없음 + [198, 73, 3.7], # 확대 — 띠 가운데 = 영역 + [198, 73, 0.5], # 축소 — 띠 가운데 = 영역 +] + + +def run(cases: list[dict]) -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_261_3_detail_pick.cjs")], + input=json.dumps({"cases": cases}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out() -> dict: + basinless = [e for e in ELEMENTS if "basin" not in e["cls"]] + return run( + [ + {"name": "sample", "axes": AXES, "elements": ELEMENTS, "clicks": CLICKS}, + {"name": "basinless", "axes": AXES, "elements": basinless}, + ] + ) + + +def face_of(case: dict, lines: list[str]) -> tuple[int, dict]: + found = [(i, f) for i, f in enumerate(case["faces"]) if f["lines"] == lines] + assert len(found) == 1, [f["lines"] for f in case["faces"]] + return found[0] + + +def piece_line(case: dict, pick: dict) -> str: + return case["pieces"][pick["piece"]]["line"] + + +# ── 깎기 토사 · 암석 영역 따로 ───────────────────────────────────────────── + + +def test_깎기_암석_토사_영역_따로_면적(out): + case = out["sample"] + _, rock = face_of(case, ["design", "rock", "structure"]) + _, soil = face_of(case, ["design", "ground", "rock", "structure"]) + assert rock["area"] == pytest.approx(CUT_ROCK, abs=1e-6) + assert soil["area"] == pytest.approx(CUT_SOIL, abs=1e-6) + # 집수정 안 = 따로(넓이 1) · 셋뿐(열린 지반 · 암선 바깥 제외) + assert sorted(round(f["area"], 6) for f in case["faces"]) == sorted( + [round(CUT_ROCK, 6), round(CUT_SOIL, 6), 1.0] + ) + + +def test_비탈_끝이_지반선에_닿기만_해도_나눔(out): + ground_ends = { + round(p["points"][k][0], 4) + for p in out["sample"]["pieces"] + if p["line"] == "ground" + for k in (0, -1) + } + assert {LEFT_GROUND, round(RIGHT_GROUND, 4)} <= ground_ends + + +def test_집수정_없으면_암석_영역_안_닫힘(out): + # 고침 전 꼴 — 설계선 끝 ~ 비탈 밑이 안 이어져 토사 띠만 남음 + faces = out["basinless"]["faces"] + assert [f["lines"] for f in faces] == [["design", "ground", "rock", "structure"]] + assert faces[0]["area"] == pytest.approx(CUT_SOIL, abs=1e-6) + + +def test_암석_영역_서버_거울(out): + _, rock = face_of(out["sample"], ["design", "rock", "structure"]) + number, _text, again = cell_value(rock["source"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(CUT_ROCK, abs=1e-3) + + +# ── 누름 판정(거리) ────────────────────────────────────────────────────── + + +def test_얇은_띠_보통_배율_가운데_누름_영역(out): + case = out["sample"] + soil, _ = face_of(case, ["design", "ground", "rock", "structure"]) + rock, _ = face_of(case, ["design", "rock", "structure"]) + picks = case["clicks"] + assert picks[0] == {"face": soil} + assert picks[4] == {"face": rock} + assert picks[7] == {"face": soil} and picks[8] == {"face": soil} + + +def test_선_가까이_누름_선(out): + case = out["sample"] + picks = case["clicks"] + assert piece_line(case, picks[1]) == "ground" + assert piece_line(case, picks[2]) == "rock" + assert piece_line(case, picks[3]) == "rock" + assert piece_line(case, picks[5]) == "ground" + assert picks[6] is None + + +# ── 실측 그림(검증_랩탑메인 4+15) ───────────────────────────────────────── + + +@pytest.fixture(scope="module") +def real() -> dict: + elements = json.loads( + (HERE / "fixtures" / "b06_261_3_cross_415.json").read_text(encoding="utf-8") + ) + ppm = 241.954642190761 / 20 # 눈금 20 m = 241.95 px · 과장 1 + axes = { + "pixelsPerMeter": ppm, + "maxOffset": (708 - PAD["left"]) / ppm, # 0 m 눈금 x 708 + "displayMax": 875 + (318.52362895766817 - PAD["top"]) / ppm, # 875 m 눈금 y 318.52 + "elevationMid": 870, + "exaggeration": 1, + } + case = {"name": "real", "axes": axes, "elements": elements} + first = run([case])["real"] + band = [(i, f) for i, f in enumerate(first["faces"]) if "ground" in f["lines"]] + soil, face = max(band, key=lambda item: item[1]["area"]) # 깎기 토사 띠 + # 띠의 지반 조각(가장 긴 것) 가운데 꼭짓점 — 그 밑 3 px(띠 가운데) · 1 px(지반선) + ground = max( + (i for i in face["edges"] if first["pieces"][i]["line"] == "ground"), + key=lambda i: len(first["pieces"][i]["points"]), + ) + path = first["px"]["paths"][ground] + x, y = path[len(path) // 2] + case["clicks"] = [[x, y + 3, 1], [x, y + 1, 1]] + return {**run([case])["real"], "soil": soil, "ground": ground} + + +def test_실측_깎기_암석_영역_생김(real): + rock = [f for f in real["faces"] if f["lines"] == ["design", "rock", "structure"]] + assert rock and max(f["area"] for f in rock) == pytest.approx(18.24, abs=0.05) + soil = real["faces"][real["soil"]] + assert soil["lines"] == ["design", "ground", "rock", "structure"] + assert soil["area"] == pytest.approx(6.00, abs=0.05) + + +def test_실측_보통_배율_띠_누름(real): + assert real["clicks"][0] == {"face": real["soil"]} + assert real["clicks"][1] == {"piece": real["ground"]} From 976bd868b977c30a346ea16c953e5323ff177097 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:40:20 +0900 Subject: [PATCH 060/151] =?UTF-8?q?fix(test):=20164=20=EC=A4=8C=20?= =?UTF-8?q?=EB=8F=84=EC=9A=B0=EB=AF=B8=EC=97=90=20=ED=91=9C=20=EC=9C=84=20?= =?UTF-8?q?CAD=20=EC=B9=B8=20=ED=9D=89=EB=82=B4=20=EB=8D=94=ED=95=A8=20(26?= =?UTF-8?q?0=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- resources/tester/helper_164_detail_zoom.cjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index a56ff0baa..c45d6ecb1 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -37,6 +37,8 @@ const STUBS = { "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, // 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`) "./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" }, + // 표 위 CAD 칸(260)도 줌과 무관 — 따로 시험(`test_260_note_cad.py`) + "./B06_Section_UI_Cross_Detail_Cad": { mountNoteCad: () => {}, stationNoLabel: () => "" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); function loadTs(full) { From 3136eb32c6d7103689efc5981d3ab314dd2cd263 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:41:47 +0900 Subject: [PATCH 061/151] =?UTF-8?q?chore(plan):=20261=20=EC=9D=B4=EA=B4=80?= =?UTF-8?q?=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=B0=EC=A0=95=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20146-6=20=C2=B7=20146-7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-11_plan_261.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/raw/plans/2026-10-11_plan_261.md diff --git a/docs/raw/plans/2026-10-11_plan_261.md b/docs/raw/plans/2026-10-11_plan_261.md new file mode 100644 index 000000000..fdd5c3548 --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_261.md @@ -0,0 +1,3 @@ +# 2026-10-11 이관 — 261 (화면 검증 흠 고침) + + From a3be7da0af4ed1b0f49c8da033332e20a4b0f5fd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:42:01 +0900 Subject: [PATCH 062/151] =?UTF-8?q?chore(plan):=20261=20=EC=9D=B4=EA=B4=80?= =?UTF-8?q?(=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-11_plan_261.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/raw/plans/2026-10-11_plan_261.md b/docs/raw/plans/2026-10-11_plan_261.md index fdd5c3548..f382de145 100644 --- a/docs/raw/plans/2026-10-11_plan_261.md +++ b/docs/raw/plans/2026-10-11_plan_261.md @@ -1,3 +1,10 @@ # 2026-10-11 이관 — 261 (화면 검증 흠 고침) - +## 261. 화면 검증 흠 고침 (브레인 화면 검증 · 사용자 163번) +정함(브레인) = 브레인 화면 검증(10-11) 흠 다섯 · 사용자 확정 = 1 소수점 제어 · 3 · 4 제안대로 · 5 이정표 항목명 그대로 개별 줄 · 2 보류 +- [x] 261-1 (sub1) 거리이정표 km · m 칸 끝수 — 시점 창 중점 측점 km 3자리(끝 0 지움 · 안 고치면 원래 값) · 구간 · 다이어그램 km 칸도 점검 — 시험 · 검증완료(test_261_1 3건 · 관련 시험 26건 통과 · typecheck 통과 · 576.595 m → 「0.577」 · 0.3 → 300 m · 안 고침 = 576.595 유지) +- [x] 261-2 (sub3) 공사원가계산서 직접재료비 이동 대상 = 총괄 「가. 순공사비계」(direct) · 모든 원가 줄 이동 대상이 총괄 줄에 있는지 시험 — 검증완료(test_261_2 2 · test_185_1 5 통과 · 원가 줄 이동 대상 전부 총괄 줄에 있음 · f0f35c574) +- [x] 261-3 (sub7) B06 상세 영역 — 보통 배율에서 영역 안 누름 = 영역(보이는 선 가까이만 선) · 설계선이 지반 · 암 띠를 나눠 깎기 토사 · 암석 영역 따로(표 값 근처) — 시험 — 검증완료(test_261_3 8 · 233_2 · 259 · 209_1 통과 · typecheck · 6 px 띠 가운데 = 영역 · 선 1 px = 선 · 4+15 깎기 암석 18.24㎡ · 토사 6.00㎡ · sub7 일반 깎기 3곳 영역 = 그림 칠 면적) +- [x] 261-4 (sub2) 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관 · 키 그대로 — 시험 + - 검증완료(test_261_4 외 5파일 26 통과 · 검증_sub2 실제 GET 운반공 10줄 「강모래 · 강모래(haul:3.1) · 전석운반 ×2 · 석산」 · 운반운반 0 · typecheck 통과) +- 보류(사용자) = 저장 경로 그림이 끌어 옮긴 시점(start_pin)을 안 씀 From e2ce3b5f2235e80bf514b147dbdd22ab4db9236a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:11:34 +0900 Subject: [PATCH 063/151] =?UTF-8?q?chore(plan):=2010-11=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20164(=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20=EA=B3=B5=EA=B8=B0=20=EC=98=B5=EC=85=98=20=C2=B7=20?= =?UTF-8?q?B06=20=EC=83=81=EC=84=B8=20CAD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index e47c8d39a..9d88e983e 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -192,3 +192,4 @@ 161. (원문) 다음은 B09에 신규 메인탭인 공기를 넣어주고 하위에 공기집계와 공기산출 탭을 만들어줘. 이때 공기관련내용은 초안이니 B09 폴더 내부에 draft폴더를 만들어서 코드를 작업해줘. 나중에 마음에 안들면 그냥 draft폴더를 삭제할거야. 일단 참고문서는 resources\knowledge\original\실무문서\01.산불거창(거창북상병곡)\01.내역서\00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx 파일 중에 공기집계와 공기산출 탭을 참고하면 될거야. 여기서 중요한 점은 참고를 보고 내용을 넣는게 중요한게 아니라 로직을 분석하고 어떻게 계산하는지 확인하고 구현하는게 중요한거야. 다시말하지만 참고 항목이 있다고 테이블을 만드는게 아니야. 우리 프로그램에 맞춰 적용하는게 중요한거야. 그래서 길게 생각하고 작업해도 되니까. 꼼꼼히 분석해서 반영해줘. 이건 현재 작업내용을 이어서 진행하면 됨. 나 쉬러 갈테니까 작업완료하고 내일 아침에 내가 아티팩트를 만들어줘. 일단 이번 구현에 대해서는 방향성 결정이 필요한 내용이 있다면 너가 결정해서 아티팩트로 알려줘. 162. (원문 · 10-11) 1. 거리이정표 - 설계내역서 운반공 연결 - 거리이정표의 항목행 하나당 하나씩 설계내역서의 운반공 하위의 노용으로 들어가야함. - 거리이정표와 설계내역서의 연결 후 선택된 항목에 대해 거리값을 입력받아야하는 경우 연결되거나 될 거리이정표의 거리와 연결되어야 하며 이때 아마 비포장과 포장이 다를것같은데 이부분에 대한 계산 절차를 보여주는 모달창이 있어야함. 이때 모달창에는 옵션값과 계산 절차를 같은 화면에서 보여주며 단가산출을 다른 항목으로 수정할수 있어야함. 단가연결이 안된경우와 된경우를 같은 모달창을 사용하면 좋을것같아. 2. 거리이정표의 하위탭중 항목별 경로는 나타내는 하위탭에서 거래처/기준점/시점을 옮긴다면 주로를 옮겨서 업데이트 3. 거리이정표의 시점에서 나오는 모달창의 중점 측점은 km 단위로 변경, 하위의 설명은 삭제, 맵은 공통 카카오맵으로 변경 4. 거리이정표의 다이어그램에서 항목의 폰트를 거래처의 폰트와 동일하게 변경, 아래 주소가 길경우 자동으로 줄변경하고 컨테이너 세로 높이 증가, 방위 배치나 기본 맵연결에 따라 위치를 잡을때 거리에 따른 선길이가 지정되면 좋겠음. 단 너무 널리떠러지면 안됨. 선, 위의 텍스트 중 포장타입과 거리는 2행 구조로 변경, 거래처 및 기준점에 대한 모달창에서 제목의 폰트사이지가 내용보다 작음, 소재지-주소찯기.지도에서 고르기(...) 항목은 삭제, 기준점의 모달창인 경유 기준점의 이름이 2행구조로 됨. , 주소 및 맵 영역은 상단에 가로선을 추가하여 구분할것. 5. 기준점의 다이어그램 컨테이너에도 주소 추가 6. 거리이정표의 다이어그램에서 선연결의 드래그하는 원을 박스의 좌측 상단에 배치, 단 테두리를 넘어가면 안됨. 추가로 컨테이너의 내부에 작은 주소를 찾은 그림을 캡쳐하여 프로젝트 영구저장소에 넣어주고 컨테이너 내부에 배치 이때 그림을 다른 곳에서도 사용할 예정임. 잘 보관할 것. 7. 거리이정표의 하위 탭의 경로보기 기능 중 그림을 캡쳐하여 프로젝트 영구저장소에 넣어줄것. 프론트엔드 기능변경이 아님. 그림은 나중에 다른곳에 사용할 예정 8. 거리이정표의 다이어그램을 누렀을때나오는 제목에 테이블 제목 행의 정보가 들어오는데 삭제. 9. 거리이정표의 테이블이 컨테이너가 다이어그램의 영역과 동일하게 브라우져의 폭에 맞춰 변경이 되지만 테이블은 맞춤이 안됨. 혹시 테이블 양식과 충돌이 있다면 알려줄것. 10. 거리이정표의 기준점은 2개 이상일 수도 있을것같음. 이럴경우를 대비하여 선연결 기능을 기준점에도 추가 11. B09 총괄설계내역서와 설계내역서를 하위탭으로 이동하고 상위 탭으로 설계내역 탭을 생성 12. B09 단가연결 열의 정보가 텍스트로 구현되어있음. 랩프로 변경(램프양식은 B08의 구조물집계표에 있음), 선택 기능은 유지, 단가연결 열 제목은 단가 > 줄바꿈 > 연결 로 변경하고 테이블 열 폭을 줄일것. 13. B09와 테이블 행선택에서 페이지이동 기능이 구현된 행의 경우 붉은색 박스 하이라이트가 반영안된게 있음. 14. 하이라이트 기능과 연결하여 조심할 부분은 셀에 수기 입력이 가능한 영역을 셀테두리로 표기한게 없어졌음. 조심 테두리의 값을 조금 더 옅게 표현. 15. 자재단가대비표에 선택한 적용단가인 경우에 해당 셀에 색상을 변경했는데 이걸 테두리를 넣는걸로 대체. 이때 입력셀 테두리와 혼돈이 될수 있으니 녹색계열로 변경 16. 자재단가대비표에서 최저가 일지라도 적용단가를 변경해야할 경우가 있음. 마우스 우클릭에 항목을 적용단가 적용 항목 변경 17. 설계내역서의 비고 열에 빈 칸XX 정보는 삭제. 18. 총괄설계내역서/공사원가계산서의 제일하단의 총공사비는 하단고정으로 변경. 양식은 테이블 양식 확인 19. B06의 횡단도에서 상세버튼 모당창의 내용 중 나눠진 면적지정에 대한 기능 구현안됨. 기준은 현재 선들이 교차가 되면 선 요소를 분할하였음. (암경계선도 분할 필요) 이렇게 됨면 오픈된 영역을 제외하면 먼저 지정도 가능해야함. 이때 선택된 면적은 선을 지정하였을때 길이관련 정보(기능 누락됨)보여주는 정보들처럼 면적 정보들을 보여주면 됨.(이때 테이블을 선택하면 그래프에서 선이나 면적을 지정하게 될텐데 이때도 길이나 면적이 보여야함. ) 20. 횡단도 상세버튼 모달창 테이블의 항목들을 선택하면 테이블 하이라이트가 되는데 여기서 셀과 셀 제목을 지정해주는건 좋은데 중복되는 선때문에 보기가 안좋음. 21. 횡단도 상세버튼 모달창에서의 그래프 영역하단은 텍스트 입력창으로 별도의 정보를 B07로 전달되어야하는데 내가 원하는 방향은(그림_162-21_횡단배수_글.png — 배수관 원 표기 · NO.71+13 · 관보호공 · 관유입부보강 글) 구조물이 없는 경우에는 당연히 빈값이어야하고 구조물 중 횡단배수 구조물에 대해서만 미리 넣는 스크립트를 만들어주면 됨. 이때 이질감이 있을수 있으니 텍스트창이아니라 캐드창을 보여주는데 그냥 배경만 있는 캐드창이기만 하면됨. 그럼 다음B07에 테이블 상단에 배치하면 이질감이 없을것같아. 기본적으로 설계자들은 대부분 캐드 단축키를 알고 있으니 빈화면의 캐드화면이라도 문제 없어보임. 22. B05/B06 공통 좌측 패널의 구조물 조합 지정 기능에서 미지정 항목에 대해 노란새 색상이 좋기는 한데 눈에 너무 띄는것같음. 이건 구조물리스트의 램프와 같은 색상으로 가는게 좋겠어. 회색이 될것같네. 23. 이제까지는 너가 직접 코드작업을 요청했지만 이제 폭넓은 부분에 코드작업을 해야하니 너가 조사작업을 하고 계획서를 작성하고 브레인으로써 다른워크트리에게 작업을 지시해줘. 그리고 완료시 너가 작업이 누락된게 없는지 확인해줘. 코더는 자기가 작업한 코드만 검증을 하고 화면 검증은 작업완료시 내가 진행할께. 너도 필요하다고 판단되면 브라우져 검증진행해도 됨. 163. (원문 · 브레인 화면 검증 흠 다섯에 대한 답) 고칠 흠중 1. 소수점 제어 확인 2. 보류 3. 제안대로 4. 제안대로 5. 운반공의 하위에는 단가연결이 연결이 되던안되던 일단 이정표의 항목명으로 운반공에 하위에 개별로 들어와야함. +164. (원문 · 10-11) 1. 거리이정표 - 거래처의 주소를 입력할때 정해져 있다면 공사지나 시점의 마킹을 남기는게 좋음> - 거리이정표의 컨테이너 모달창에서 제일 상단의 제목이 거래처 - 거래처, 공사지 - 시점,종점 , 기준점 - 주소 로 표현됨. 에서 - 앞의 글자는 필요없음 - 단가연결에서 수기로 전체입력을 받는게 아님. 사용자는 미리만들어진 단가산출 로직이나 구조물 조합, 일위대가 사전조합에서 지정하게 될텐데 이때 지정한 항목이 필요한 입력정보를 보여주는데 여기서 이정표로 거리값을 확보했으니 입력에 자동으로 넣어달라는 말이잖아. 처음부터 값을 하나하나 지정해서 만든다는게 아니라. 그리고 어차피 너가만든 기준으로 만든 8-2-8 도 벌써 만들어져 있어. 입력값이 필요할 뿐이지. 2. B09 공기초안의 아티팩트는 봤어. 그런데 이상한건 너가 정한것들은 모두 사용자가 지정해야할 내용들 아니야? 그렇다면 B09의 좌측패널에 별도의 컨테이너를 만들어서 옵션을 선택할 수 있게 변경해줘. 3. B06 횡단도 상세 모달창의 내부의 캐드영역 구현은 잘되었음. 여기서 접었다 폈다하는 기능을 구현해주고 일반측점의 경우에는 접힌상태가 구조물 측점의 경우에는 펼쳐진 상태가 모달창이 열릴때 기본값으로 변경하고 캐드영역과 테이블 순서를 바꿔줘. 4. B06 횡단도 상세 모달창의 내부 횡단도에서의 기본 줌인값은 B06 좌측패널의 횡단반폭 보기설정값을 적용하게 해줘. 5. B06 횡단도 상세 모달창인 캐드화면에 기본 값을 배관만 넣어줬는데 다른 구조물도 넣어야하고 문구를 그냥 내가 넣어준 값을 복사하여 사용한것 같은데 여기서 구조물조합에 연결된 일위대가의 이름과 관련 정보(옵션 값)들을 넣어주면 됨. 그럼 사용자가 수정하여 사용함. (배관은 원형마크로 대체 되어야함. ) 6. B06 횡단도의 상세 모달창의 캐드화면에서 배관의 길이는 정수값을 사용해야함. 사사오입한 값을 사용해야함. 벌써 내부에 계산되어 있음. 7. B06 횡단도에 표기되니 관매설 정보를 알려주는 노폭 정보위의 회색 글씨의 크기는 노폭정보 폰트 크기와 동일하게 변경 From 81950486c759ff1d228682b2de9231dbb824023d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:28:36 +0900 Subject: [PATCH 064/151] =?UTF-8?q?chore(plan):=2010-11=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20165(B07=20=EC=B8=A1=EA=B5=AC=20=C2=B7=20=EA=B4=80?= =?UTF-8?q?=20=EC=83=89=20=C2=B7=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=96=91=EC=8B=9D=20=C2=B7=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=97=B0=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 9d88e983e..8eff8ad0a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -193,3 +193,4 @@ 162. (원문 · 10-11) 1. 거리이정표 - 설계내역서 운반공 연결 - 거리이정표의 항목행 하나당 하나씩 설계내역서의 운반공 하위의 노용으로 들어가야함. - 거리이정표와 설계내역서의 연결 후 선택된 항목에 대해 거리값을 입력받아야하는 경우 연결되거나 될 거리이정표의 거리와 연결되어야 하며 이때 아마 비포장과 포장이 다를것같은데 이부분에 대한 계산 절차를 보여주는 모달창이 있어야함. 이때 모달창에는 옵션값과 계산 절차를 같은 화면에서 보여주며 단가산출을 다른 항목으로 수정할수 있어야함. 단가연결이 안된경우와 된경우를 같은 모달창을 사용하면 좋을것같아. 2. 거리이정표의 하위탭중 항목별 경로는 나타내는 하위탭에서 거래처/기준점/시점을 옮긴다면 주로를 옮겨서 업데이트 3. 거리이정표의 시점에서 나오는 모달창의 중점 측점은 km 단위로 변경, 하위의 설명은 삭제, 맵은 공통 카카오맵으로 변경 4. 거리이정표의 다이어그램에서 항목의 폰트를 거래처의 폰트와 동일하게 변경, 아래 주소가 길경우 자동으로 줄변경하고 컨테이너 세로 높이 증가, 방위 배치나 기본 맵연결에 따라 위치를 잡을때 거리에 따른 선길이가 지정되면 좋겠음. 단 너무 널리떠러지면 안됨. 선, 위의 텍스트 중 포장타입과 거리는 2행 구조로 변경, 거래처 및 기준점에 대한 모달창에서 제목의 폰트사이지가 내용보다 작음, 소재지-주소찯기.지도에서 고르기(...) 항목은 삭제, 기준점의 모달창인 경유 기준점의 이름이 2행구조로 됨. , 주소 및 맵 영역은 상단에 가로선을 추가하여 구분할것. 5. 기준점의 다이어그램 컨테이너에도 주소 추가 6. 거리이정표의 다이어그램에서 선연결의 드래그하는 원을 박스의 좌측 상단에 배치, 단 테두리를 넘어가면 안됨. 추가로 컨테이너의 내부에 작은 주소를 찾은 그림을 캡쳐하여 프로젝트 영구저장소에 넣어주고 컨테이너 내부에 배치 이때 그림을 다른 곳에서도 사용할 예정임. 잘 보관할 것. 7. 거리이정표의 하위 탭의 경로보기 기능 중 그림을 캡쳐하여 프로젝트 영구저장소에 넣어줄것. 프론트엔드 기능변경이 아님. 그림은 나중에 다른곳에 사용할 예정 8. 거리이정표의 다이어그램을 누렀을때나오는 제목에 테이블 제목 행의 정보가 들어오는데 삭제. 9. 거리이정표의 테이블이 컨테이너가 다이어그램의 영역과 동일하게 브라우져의 폭에 맞춰 변경이 되지만 테이블은 맞춤이 안됨. 혹시 테이블 양식과 충돌이 있다면 알려줄것. 10. 거리이정표의 기준점은 2개 이상일 수도 있을것같음. 이럴경우를 대비하여 선연결 기능을 기준점에도 추가 11. B09 총괄설계내역서와 설계내역서를 하위탭으로 이동하고 상위 탭으로 설계내역 탭을 생성 12. B09 단가연결 열의 정보가 텍스트로 구현되어있음. 랩프로 변경(램프양식은 B08의 구조물집계표에 있음), 선택 기능은 유지, 단가연결 열 제목은 단가 > 줄바꿈 > 연결 로 변경하고 테이블 열 폭을 줄일것. 13. B09와 테이블 행선택에서 페이지이동 기능이 구현된 행의 경우 붉은색 박스 하이라이트가 반영안된게 있음. 14. 하이라이트 기능과 연결하여 조심할 부분은 셀에 수기 입력이 가능한 영역을 셀테두리로 표기한게 없어졌음. 조심 테두리의 값을 조금 더 옅게 표현. 15. 자재단가대비표에 선택한 적용단가인 경우에 해당 셀에 색상을 변경했는데 이걸 테두리를 넣는걸로 대체. 이때 입력셀 테두리와 혼돈이 될수 있으니 녹색계열로 변경 16. 자재단가대비표에서 최저가 일지라도 적용단가를 변경해야할 경우가 있음. 마우스 우클릭에 항목을 적용단가 적용 항목 변경 17. 설계내역서의 비고 열에 빈 칸XX 정보는 삭제. 18. 총괄설계내역서/공사원가계산서의 제일하단의 총공사비는 하단고정으로 변경. 양식은 테이블 양식 확인 19. B06의 횡단도에서 상세버튼 모당창의 내용 중 나눠진 면적지정에 대한 기능 구현안됨. 기준은 현재 선들이 교차가 되면 선 요소를 분할하였음. (암경계선도 분할 필요) 이렇게 됨면 오픈된 영역을 제외하면 먼저 지정도 가능해야함. 이때 선택된 면적은 선을 지정하였을때 길이관련 정보(기능 누락됨)보여주는 정보들처럼 면적 정보들을 보여주면 됨.(이때 테이블을 선택하면 그래프에서 선이나 면적을 지정하게 될텐데 이때도 길이나 면적이 보여야함. ) 20. 횡단도 상세버튼 모달창 테이블의 항목들을 선택하면 테이블 하이라이트가 되는데 여기서 셀과 셀 제목을 지정해주는건 좋은데 중복되는 선때문에 보기가 안좋음. 21. 횡단도 상세버튼 모달창에서의 그래프 영역하단은 텍스트 입력창으로 별도의 정보를 B07로 전달되어야하는데 내가 원하는 방향은(그림_162-21_횡단배수_글.png — 배수관 원 표기 · NO.71+13 · 관보호공 · 관유입부보강 글) 구조물이 없는 경우에는 당연히 빈값이어야하고 구조물 중 횡단배수 구조물에 대해서만 미리 넣는 스크립트를 만들어주면 됨. 이때 이질감이 있을수 있으니 텍스트창이아니라 캐드창을 보여주는데 그냥 배경만 있는 캐드창이기만 하면됨. 그럼 다음B07에 테이블 상단에 배치하면 이질감이 없을것같아. 기본적으로 설계자들은 대부분 캐드 단축키를 알고 있으니 빈화면의 캐드화면이라도 문제 없어보임. 22. B05/B06 공통 좌측 패널의 구조물 조합 지정 기능에서 미지정 항목에 대해 노란새 색상이 좋기는 한데 눈에 너무 띄는것같음. 이건 구조물리스트의 램프와 같은 색상으로 가는게 좋겠어. 회색이 될것같네. 23. 이제까지는 너가 직접 코드작업을 요청했지만 이제 폭넓은 부분에 코드작업을 해야하니 너가 조사작업을 하고 계획서를 작성하고 브레인으로써 다른워크트리에게 작업을 지시해줘. 그리고 완료시 너가 작업이 누락된게 없는지 확인해줘. 코더는 자기가 작업한 코드만 검증을 하고 화면 검증은 작업완료시 내가 진행할께. 너도 필요하다고 판단되면 브라우져 검증진행해도 됨. 163. (원문 · 브레인 화면 검증 흠 다섯에 대한 답) 고칠 흠중 1. 소수점 제어 확인 2. 보류 3. 제안대로 4. 제안대로 5. 운반공의 하위에는 단가연결이 연결이 되던안되던 일단 이정표의 항목명으로 운반공에 하위에 개별로 들어와야함. 164. (원문 · 10-11) 1. 거리이정표 - 거래처의 주소를 입력할때 정해져 있다면 공사지나 시점의 마킹을 남기는게 좋음> - 거리이정표의 컨테이너 모달창에서 제일 상단의 제목이 거래처 - 거래처, 공사지 - 시점,종점 , 기준점 - 주소 로 표현됨. 에서 - 앞의 글자는 필요없음 - 단가연결에서 수기로 전체입력을 받는게 아님. 사용자는 미리만들어진 단가산출 로직이나 구조물 조합, 일위대가 사전조합에서 지정하게 될텐데 이때 지정한 항목이 필요한 입력정보를 보여주는데 여기서 이정표로 거리값을 확보했으니 입력에 자동으로 넣어달라는 말이잖아. 처음부터 값을 하나하나 지정해서 만든다는게 아니라. 그리고 어차피 너가만든 기준으로 만든 8-2-8 도 벌써 만들어져 있어. 입력값이 필요할 뿐이지. 2. B09 공기초안의 아티팩트는 봤어. 그런데 이상한건 너가 정한것들은 모두 사용자가 지정해야할 내용들 아니야? 그렇다면 B09의 좌측패널에 별도의 컨테이너를 만들어서 옵션을 선택할 수 있게 변경해줘. 3. B06 횡단도 상세 모달창의 내부의 캐드영역 구현은 잘되었음. 여기서 접었다 폈다하는 기능을 구현해주고 일반측점의 경우에는 접힌상태가 구조물 측점의 경우에는 펼쳐진 상태가 모달창이 열릴때 기본값으로 변경하고 캐드영역과 테이블 순서를 바꿔줘. 4. B06 횡단도 상세 모달창의 내부 횡단도에서의 기본 줌인값은 B06 좌측패널의 횡단반폭 보기설정값을 적용하게 해줘. 5. B06 횡단도 상세 모달창인 캐드화면에 기본 값을 배관만 넣어줬는데 다른 구조물도 넣어야하고 문구를 그냥 내가 넣어준 값을 복사하여 사용한것 같은데 여기서 구조물조합에 연결된 일위대가의 이름과 관련 정보(옵션 값)들을 넣어주면 됨. 그럼 사용자가 수정하여 사용함. (배관은 원형마크로 대체 되어야함. ) 6. B06 횡단도의 상세 모달창의 캐드화면에서 배관의 길이는 정수값을 사용해야함. 사사오입한 값을 사용해야함. 벌써 내부에 계산되어 있음. 7. B06 횡단도에 표기되니 관매설 정보를 알려주는 노폭 정보위의 회색 글씨의 크기는 노폭정보 폰트 크기와 동일하게 변경 +165. (원문 · 10-11) 1. B07의 횡단도에 측구를 표현할때 노란색으로 측구가 하나더있어보임. 확인후 삭제 2. B07의 배관이 구분이 어려움, 색상변경,(박스암거도) 3. B07 좌측 패널의 사이즈는 요소들의 버튼과 입력창의 양식은 B05/B06 좌측 패널의 구조물 배치의 공용양식 사용. 이때 B06의 다른 컨테이너도 포함. B05 이상의 페이지들의 좌측패널의 컨테이너 공용양식 사용확인, 이때 패널의 하단에 버튼셋들은 예외. 4. B05/B06 좌측패널의 구조물 리스트나 B06의 횡단도, 배수유역도의 번호 및 영역, 테이블열, 3D 측점 하이라이트 기능은 다연동되어 있는데 지금 배수유역과 테이블 열 하이라이트 기능을 잃은것같아. 이건 정말 조심히 개선해야하는데 어제 저녁쯤에 상당히 오랫동안 분석해서 나온결과야. 주요 문제점은 노면공의 범위 값에 의한 하이라이트 충돌문제였어. 제대로 확인한 후에 4번만 너가 직접 작업하고 브라우져 검증해줘. From b2ad4f7e406b10d08fb3fae5a7c53f4034ad8fd2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:33:24 +0900 Subject: [PATCH 065/151] =?UTF-8?q?feat(B08):=20262=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=20=EC=B0=BD=20=EC=A0=9C=EB=AA=A9=20=EC=9D=B4=EB=A6=84=EB=A7=8C?= =?UTF-8?q?=20=C2=B7=20=EC=86=8C=EC=9E=AC=EC=A7=80=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=ED=91=9C=EC=A7=80=20=EA=B3=A0=EC=A0=95?= 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_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_UI_Milestone.css | 13 ++++++ .../B08_Quantity_UI_Milestone_KakaoMap.ts | 18 ++++++++- .../B08_Quantity_UI_Milestone_Modal.ts | 6 +-- .../B08_Quantity_UI_Milestone_Place.ts | 34 ++++++++++------ .../test_254_3_b08_milestone_box_modal_pic.py | 4 +- ...est_262_1_b08_milestone_title_start_pin.py | 40 +++++++++++++++++++ 6 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_262_1_b08_milestone_title_start_pin.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 4d0df3b34..09b24921d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -204,6 +204,19 @@ white-space: nowrap; } +/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */ +.b08-milestone__map-start { + padding: 3px 10px; + border: 2px solid #fff; + border-radius: 999px; + background: #e60046; + color: #fff; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + pointer-events: none; +} + /* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ .ui-modal.b08-milestone__modal { width: min(960px, 94vw); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 956d534ff..a6c2af12f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -98,6 +98,8 @@ export interface MapPin { lon: number; lat: number; movable: boolean; + /** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */ + fixed?: boolean; } export interface EditMap { @@ -127,8 +129,9 @@ export function editMap( loadKakaoMaps(projectId).then( (maps) => requestAnimationFrame(() => { + const moving = pins.filter((p) => !p.fixed); const points = [ - ...pins.map((p) => new maps.LatLng(p.lat, p.lon)), + ...moving.map((p) => new maps.LatLng(p.lat, p.lon)), ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), ]; const c = opts.center?.(); @@ -156,6 +159,17 @@ export function editMap( ), ...pins.flatMap((p) => { const at = new maps.LatLng(p.lat, p.lon); + if (p.fixed) + return [ + new maps.CustomOverlay({ + position: at, + content: el("span", { + className: "b08-milestone__map-start", + text: p.name, + }), + yAnchor: 0.5, + }), + ]; const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable }); if (p.movable) maps.event.addListener(marker, "dragend", () => { @@ -176,7 +190,7 @@ export function editMap( if (fitted) { // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) const view = map.getBounds(); - const out = pins.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); + const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); if (out) map.panTo(new maps.LatLng(out.lat, out.lon)); } else if (points.length > 1) { const bounds = new maps.LatLngBounds(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 0e3394a59..97c93a15f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -106,8 +106,8 @@ export function openPlaceModal( const title = target.add ? "요소 추가 — 기준점 · 거래처" : base - ? `기준점 — ${base.name || "기준점"}` - : `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음 + ? base.name || "기준점" + : route!.name || "거래처"; // 262-1 「—」 앞 글자 없음 openModal({ title, closeLabel: "닫기", @@ -259,7 +259,7 @@ export interface SiteAddress { /** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ - title: "공사지 — 시점 · 중점", + title: "시점 · 중점", closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts index aa65196e9..f4f2af5be 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -12,7 +12,7 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { request } from "./B08_Quantity_UI_Hydro"; import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap"; -import { siteInfo } from "./B08_Quantity_UI_Milestone_State"; +import { liveMilestone, startPoint } from "./B08_Quantity_UI_Milestone_State"; export interface Place { address: string; @@ -52,27 +52,35 @@ export function placePicker(projectId: string, initial: Place, name: () => strin list.hidden = true; const where = el("p", { className: "b08-milestone__place" }); where.dataset.b08MilestonePlace = "value"; + // 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점) + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); const map = editMap(projectId, { hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", onClick: (lon, lat) => void moveTo(lon, lat), onMove: (_key, lon, lat) => void moveTo(lon, lat), - center: () => siteInfo()?.start_lonlat ?? null, + center: start, }); const draw = (): void => { where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`; + const s = start(); map.set( - place.lon != null && place.lat != null - ? [ - { - key: "place", - name: name() || "소재지", - lon: place.lon, - lat: place.lat, - movable: true, - }, - ] - : [], + [ + ...(s + ? [{ key: "start", name: "시점", lon: s[0], lat: s[1], movable: false, fixed: true }] + : []), + ...(place.lon != null && place.lat != null + ? [ + { + key: "place", + name: name() || "소재지", + lon: place.lon, + lat: place.lat, + movable: true, + }, + ] + : []), + ], [], ); }; diff --git a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py index aeec089ba..0a78098f0 100644 --- a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py +++ b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py @@ -1,6 +1,6 @@ """254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8). -① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음) +① 상자 누름 모달 제목 = 이름만(262-1 · 「—」 앞 글자 없음 · 표 머리 글 「항목 정보」 없음) ② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로) """ @@ -67,7 +67,7 @@ def test_장소_그림_상자_안_글_아래(): def test_모달_제목_화면_그림(): modal = _read(B08 + "UI_Milestone_Modal.ts") - assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal + assert 'route!.name || "거래처"' in modal and 'base.name || "기준점"' in modal # 262-1 assert "항목 정보 —" not in modal ui = _read(B08 + "UI_Milestone_Diagram.ts") assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui diff --git a/resources/tester/test_262_1_b08_milestone_title_start_pin.py b/resources/tester/test_262_1_b08_milestone_title_start_pin.py new file mode 100644 index 000000000..c002f4175 --- /dev/null +++ b/resources/tester/test_262_1_b08_milestone_title_start_pin.py @@ -0,0 +1,40 @@ +"""262 B08 거리이정표 상자 창 제목 · 소재지 지도 시점 표지(사용자 164-1). + +262-1 거래처 · 기준점 창 제목 = 이름만(빈 이름 = 「거래처」 · 「기준점」) · 시점 창 = 「시점 · 중점」 · 요소 추가 창 그대로 +262-2 소재지 지도 = 시점 표지 고정(끌기 없음 · 맞춤에서 빠짐) · 시점 없으면 표지 없음 · 소재지 없으면 시점 중심 +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B08 = "B08_Quantity/B08_Quantity_UI_Milestone" + + +def _read(name): + return (ROOT / f"{B08}{name}").read_text(encoding="utf-8") + + +def test_제목_이름만(): + modal = _read("_Modal.ts") + title = modal[modal.index("const title = target.add") : modal.index("openModal({")] + assert '? base.name || "기준점"' in title and ': route!.name || "거래처"' in title + assert '"요소 추가 — 기준점 · 거래처"' in title + assert 'title: "시점 · 중점",' in modal and "공사지 —" not in modal + assert re.search(r"title: `?[^\n]*(거래처|기준점|공사지) —", modal) is None + + +def test_시점_표지_고정(): + place = _read("_Place.ts") + assert "startPoint(liveMilestone(projectId))" in place # 거리이정표가 쓰는 시점 + assert "center: start" in place # 소재지 없음 = 시점 중심 + assert "movable: false, fixed: true" in place and 'name: "시점"' in place + assert "...(s" in place and " : [])," in place # 시점 없음 = 표지 없음 + kakao = _read("_KakaoMap.ts") + assert "fixed?: boolean;" in kakao + assert "const moving = pins.filter((p) => !p.fixed);" in kakao + assert "...moving.map(" in kakao # 처음 보기 = 소재지 중심(시점은 맞춤에서 빠짐) + assert "moving.find(" in kakao # 보기 이동에서도 빠짐 + assert "if (p.fixed)" in kakao and "b08-milestone__map-start" in kakao + css = _read(".css") + assert ".b08-milestone__map-start {" in css From 5fdfc64d108cd3abb5179c6bace19ab49ea949e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:33:24 +0900 Subject: [PATCH 066/151] =?UTF-8?q?fix(B06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=20=EA=B4=80=EB=A7=A4=EC=84=A4=20=C2=B7=20=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=20=EC=A0=95=EB=B3=B4=20=EA=B8=80=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=20=3D=20=EB=85=B8=ED=8F=AD=20=EB=9D=BC=EB=B2=A8=20=EA=B8=80=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0(267-1)?= 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_015sDZSjFvoiDjdGpgj3muc3 --- .../B06_Section_UI_Style_Cross_Areas.css | 5 +++ resources/tester/test_267_1_pipe_info_font.py | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 resources/tester/test_267_1_pipe_info_font.py diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0b21a02b5..a724bc1cc 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -360,6 +360,11 @@ fill: var(--color-text-secondary); } +/* 관 정보 글(관매설 · 세월교 · 267-1) — 노폭 라벨(`.b06-chart__carriageway-label`)과 같은 글 크기 */ +.b06-chart__culvert-label.b06-chart__pipe-info { + font-size: 11px; +} + /* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */ .b06-chart__culvert-cut { stroke: var(--color-warning); diff --git a/resources/tester/test_267_1_pipe_info_font.py b/resources/tester/test_267_1_pipe_info_font.py new file mode 100644 index 000000000..d819bb537 --- /dev/null +++ b/resources/tester/test_267_1_pipe_info_font.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""PLAN 267-1 — B06 횡단 카드 「관매설 …」 글 크기 = 「노폭 …」 글 크기 · 줄 겹침 없음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_267_1_pipe_info_font.py -q -p no:cacheprovider --basetemp=tmp/pt +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8") +TYPES = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Types.ts").read_text(encoding="utf-8") + + +def _font_px(selector: str) -> float: + """셀렉터 블록의 마지막 font-size(px) — 뒤에 오는 규칙이 이긴다.""" + blocks = re.findall(re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + sizes = [float(m) for b in blocks for m in re.findall(r"font-size:\s*([\d.]+)px", b)] + assert sizes, selector + return sizes[-1] + + +def test_관_정보_글_크기가_노폭_라벨과_같다() -> None: + road = _font_px(".b06-chart__carriageway-label") + info = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info") + assert road == info == 11.0 + # 다른 culvert-label(집수정 · 박스 이름)은 그대로 9px + assert _font_px(".b06-chart__culvert-label") == 9.0 + + +def test_관_정보_글_줄이_노폭_라벨과_겹치지_않는다() -> None: + step = float(re.search(r"- (\d+) \* \(stacked \+ 1\)", TYPES).group(1)) + font = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info") + road_font = _font_px(".b06-chart__carriageway-label") + asc, desc = 0.8, 0.2 # 글자 상자 근사 — 기준선 위 80% · 아래 20% + road_top = 0 - asc * road_font # 노폭 라벨 기준선 y=0 + for n in (1, 2, 3): # 관 정보 글이 쌓인 줄 수 + info_y = -step * n + info_bottom = info_y + desc * font + assert info_bottom < road_top, (n, info_bottom, road_top) + if n > 1: # 윗줄 바닥 < 아랫줄 윗면 + upper_bottom = -step * n + desc * font + lower_top = -step * (n - 1) - asc * font + assert upper_bottom < lower_top, n From c47e9f861537574b2bba2dda9026377e9b62f57c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:34:15 +0900 Subject: [PATCH 067/151] =?UTF-8?q?fix(B06):=20=EC=83=81=EC=84=B8=20CAD=20?= =?UTF-8?q?=EC=B9=B8=20=EC=9B=90=ED=98=95=20=EB=A7=88=ED=81=AC=20=EA=B4=80?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=B4=20=EC=82=AC=EC=82=AC=EC=98=A4=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EC=88=98(265-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원형 마크 L = pipeLengthWhole 정수(8.5 → 9 · 8.4 → 8) · B07 짝 같은 규칙 - 거울 시험 test_265_note_cad · test_260 기대값 L=22m Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B06_Section_UI_Cross_Detail_Cad.ts | 6 +- ...B07_DesignDetail_Engine_Cad_CulvertNote.py | 7 +- resources/tester/helper_260_note_cad.cjs | 4 ++ resources/tester/test_260_note_cad.py | 6 +- resources/tester/test_265_note_cad.py | 66 +++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_265_note_cad.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index 3cb8b04b4..cf3fff35c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -19,6 +19,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const"; const noteKey = (chainageM: number): string => chainageM.toFixed(2); @@ -109,6 +110,9 @@ export function legacyNoteEntities(note: string | undefined): Record typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : ""); +/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */ +const wholeMetres = (value: number | null | undefined): string => + typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : ""; const trim = (value: number): string => String(Math.round(value * 100) / 100); /** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */ const bondOf = (form: string | null | undefined): string => @@ -183,7 +187,7 @@ export function culvertNoteEntities( const inside = [ "배수관", `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, - `L=${metres(section.design?.pipe_length_m)}`, + `L=${wholeMetres(section.design?.pipe_length_m)}`, ]; const right = [ station, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py index 53dc81fa1..18c584202 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -40,6 +40,11 @@ def _metres(value: Any) -> str: return f"{_one(value)}m" if _one(value) else "" +def _whole_metres(value: Any) -> str: + """관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값).""" + return f"{_js_round(value)}m" if _one(value) else "" + + def _trim(value: float) -> str: rounded = _js_round(value * 100) / 100 return str(int(rounded)) if rounded == int(rounded) else str(rounded) @@ -159,7 +164,7 @@ def culvert_note_entities( inside = [ "배수관", f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m", - f"L={_metres(length)}", + f"L={_whole_metres(length)}", ] chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {} right = [ diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs index 4cd84f09e..bf9ff6244 100644 --- a/resources/tester/helper_260_note_cad.cjs +++ b/resources/tester/helper_260_note_cad.cjs @@ -28,6 +28,10 @@ function loadTs(file) { return box.exports; } +// 관 길이 정수(265-3) = 실제 `pipeLengthWhole`(타입 import 만 있는 파일) +STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"), +); const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); const wall = (role, form, height, length) => ({ diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 5a85aed87..34aae9442 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -107,7 +107,7 @@ def test_관_유입_유출_도수로_한_벌(out): assert _labels(full) == [ "배수관", "Φ1200m/m", - "L=22.0m", + "L=22m", "NO.71+13", "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", @@ -136,7 +136,7 @@ def test_도수로_옵션이_없으면_세트_구조만(out): def test_보호공을_안_고르면_관과_측점만(out): - assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"] + assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] @pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) @@ -425,7 +425,7 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_ assert labels[2:] == [ "배수관", "Φ1200m/m", - "L=22.0m", + "L=22m", "NO.71+13", "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", diff --git a/resources/tester/test_265_note_cad.py b/resources/tester/test_265_note_cad.py new file mode 100644 index 000000000..c92db042e --- /dev/null +++ b/resources/tester/test_265_note_cad.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수. + +- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값). + +./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section.B06_Section_Engine_Culvert import _culvert_set # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + culvert_note_entities, +) + +HERE = Path(__file__).resolve().parent +_PIPE = {"pipe_diameter_mm": 800} + + +def _mirror(cases: list[dict]) -> list[dict]: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], + input=json.dumps(cases, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"] + + +# ── 265-3 관 길이 정수 ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("length", "label"), + [(8.5, "L=9m"), (8.4, "L=8m"), (8.45, "L=8m"), (7.0, "L=7m"), (None, "L=")], +) +def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label): + design = {} if length is None else {"pipe_length_m": length} + culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0} + case = { + "section": {"chainage_m": 40.0, "culvert": culvert, "design": design}, + "station": "NO.2", + "options": _PIPE, + "chainage": 40.0, + "interval": 20.0, + } + [ts] = _mirror([case]) + py = culvert_note_entities(40.0, culvert, design, "NO.2", _PIPE) + assert _labels(ts["entities"])[2] == label + assert _labels(py)[2] == label From 3a08e4df9da916d8e476d10688510d2991085150 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:35:42 +0900 Subject: [PATCH 068/151] =?UTF-8?q?feat(B06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=B0=BD=20=EC=B2=98=EC=9D=8C=20=EA=B0=80=EB=A1=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=3D=20=ED=9A=A1=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=98=ED=8F=AD=20(266-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸 맞춤이 넓힌 가로를 처음 배율(homeScale)로 되좁힘 · 개별 표시 반폭 측점은 그 값 - 공용 줌 · 팬 homeScale 칸 — 처음 · [⤢] · 더블클릭 = 그 배율 · 줄이면 칸 맞춤 전체까지 - 시험 test_266 · 164 · 173 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/b_svg_zoom_pan.ts | 20 ++-- B06_Section/B06_Section_UI_Cross_Detail.ts | 21 +++- B06_Section/B06_Section_UI_Section_View.ts | 4 +- .../B06_Section_UI_Section_View_Types.ts | 4 +- resources/tester/helper_266_detail_home.cjs | 110 ++++++++++++++++++ resources/tester/test_164_detail_zoom.py | 7 +- resources/tester/test_173_areas_detail.py | 5 +- resources/tester/test_266_detail_home.py | 85 ++++++++++++++ 8 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_266_detail_home.cjs create mode 100644 resources/tester/test_266_detail_home.py diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 951564e82..5dc6d9c80 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -61,6 +61,8 @@ export interface SvgZoomPanOptions { maxScale?: number; /** 이전 상태 — 다시 그려도 배율이 살아남게. */ initial?: ZoomPanState; + /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ + homeScale?: number; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -85,9 +87,15 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { height: Math.max(heightPx - pad.top - pad.bottom, 1), }; const maxScale = options.maxScale ?? 8; - let scale = initial?.scale ?? 1; - let tx = initial?.tx ?? 0; - let ty = initial?.ty ?? 0; + const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); + const home: ZoomPanState = { + scale: homeScale, + tx: (plot.x + plot.width / 2) * (1 - homeScale), + ty: (plot.y + plot.height / 2) * (1 - homeScale), + }; + let scale = initial?.scale ?? home.scale; + let tx = initial?.tx ?? home.tx; + let ty = initial?.ty ?? home.ty; const applyTransform = (): void => { layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); onChange?.({ scale, tx, ty }); @@ -182,7 +190,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /* 이미 해제됨 */ } }; - if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { + if (scale !== 1 || tx !== 0 || ty !== 0) { clampPan(); applyTransform(); } @@ -209,9 +217,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { } const currentScale = (): number => scale; const reset = (): void => { - scale = 1; - tx = 0; - ty = 0; + ({ scale, tx, ty } = home); applyTransform(); }; // 더블클릭 원복. diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index b82ab5469..d73a204e5 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -45,12 +45,12 @@ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; - /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */ + /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */ chart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; } let host: CrossDetailHost | null = null; @@ -187,12 +187,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · - * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · + * 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */ export function attachDetailZoom( clone: SVGSVGElement, axes: CrossAxesOptions | null, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, + homeScale = 1, ): ZoomPanHandle | null { const layer = clone.querySelector("g.b06-chart__plot-layer"); if (!layer) return null; @@ -223,6 +225,7 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, + homeScale, onChange: (state) => { follow(state); onChange(state); @@ -251,7 +254,7 @@ function drawChart( section: CrossSection, widthPx: number, heightPx: number, -): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null { +): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null { drawing = true; try { const drawn = host?.chart(section.station_id, widthPx, heightPx); @@ -264,7 +267,7 @@ function drawChart( .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); - return { clone, axes: drawn.axes }; + return { clone, axes: drawn.axes, homeScale: drawn.homeScale }; } finally { drawing = false; } @@ -359,7 +362,13 @@ export function openCrossDetail(section: CrossSection): void { else { const { clone } = drawn; axes = drawn.axes; - const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + const handle = attachDetailZoom( + clone, + axes, + zoom, + (next) => (zoom = next), + drawn.homeScale, + ); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); tablePick.redraw(); // 선 조각 고르기(209-1) } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88f3a9e64..dc327f46f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -579,7 +579,9 @@ export function createSectionView( elevationMid: plot.elevationMid, exaggeration: Math.max(currentExaggeration, 0.1), }; - return { svg, axes }; + // 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1) + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + return { svg, axes, homeScale }; }, focusStation(stationId) { if (stationId === null) clearSelection(); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 939e33f2b..a67c0d508 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -39,12 +39,12 @@ export interface SectionViewController { /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null) - * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */ + * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */ detailChart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/resources/tester/helper_266_detail_home.cjs b/resources/tester/helper_266_detail_home.cjs new file mode 100644 index 000000000..a5dfdbdbf --- /dev/null +++ b/resources/tester/helper_266_detail_home.cjs @@ -0,0 +1,110 @@ +/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭. + * 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림. + * 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인). + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]); +const STUBS = { + "./B06_Section_UI_Section_Common": { + CROSS_PAD, + CROSS_HEIGHT, + validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m), + }, +}; +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => STUBS[request] ?? {}; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts")); +const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); + +function fakeSvg(width, height) { + const listeners = {}; + const attrs = {}; + return { + attrs, + setAttribute: (name, value) => (attrs[name] = String(value)), + getAttribute: (name) => attrs[name] ?? null, + classList: { add() {}, remove() {} }, + addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), + fire: (type, event) => + (listeners[type] ?? []).forEach((fn) => + fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }), + ), + setPointerCapture() {}, + releasePointerCapture() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + }; +} + +// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게) +const section = { + samples: Array.from({ length: 61 }, (_, i) => ({ + offset_m: i - 30, + elevation_m: 100 + 0.6 * (i - 30), + })), +}; + +/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */ +function open(halfWidth, W, H) { + const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100); + const fitted = metrics.fitPlotBase(base, H); + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + const svg = fakeSvg(W, H); + const layer = fakeSvg(W, H); + let state = null; + const handle = attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: CROSS_PAD, + maxScale: 16, + content: { x: -W, y: -H, width: 3 * W, height: 3 * H }, + wheelZoom: true, + homeScale, + onChange: (next) => (state = next), + }); + // 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m) + const range = () => { + const s = state ?? { scale: 1, tx: 0, ty: 0 }; + const toM = (x) => + fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter; + return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000); + }; + const out = { homeScale, widened: fitted !== base, first: range() }; + svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 }); + out.wheelScale = state.scale / homeScale; + svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); + svg.fire("pointermove", { clientX: 160, clientY: 100 }); + svg.fire("pointerup", {}); + out.panned = range()[0] !== out.first[0]; + handle.reset(); // [⤢] + out.reset = range(); + for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 }); + out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지 + svg.fire("dblclick", {}); + out.dblclick = range(); + return out; +} + +process.stdout.write( + JSON.stringify({ + global15: open(15, 1200, 300), + station10: open(10, 1200, 300), + tall15: open(15, 1200, 900), + }), +); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 1b14444f5..96e1842e4 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율(): css = _read("B06_Section_UI_Style_Cross_Detail.css") assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src - assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src + flat = " ".join(src.split()) + assert ( + "attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat + ) assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3) assert 'clone.querySelector("g.b06-chart__plot-layer")' in src assert "createCrossAxes(axisLayer, axes)" in src view = _read("B06_Section_UI_Section_View.ts") assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view - assert "return { svg, axes };" in view + assert "return { svg, axes, homeScale };" in view assert "position: relative;" in css # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py index 0ac4e246e..a772fc015 100644 --- a/resources/tester/test_173_areas_detail.py +++ b/resources/tester/test_173_areas_detail.py @@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선(): assert "cardZoomStates.delete(stationId);" in view types = _read("B06_Section_UI_Section_View_Types.ts") # 179-3 — svg + 축 옵션 - assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types + assert ( + " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;" + in types + ) assert ( " detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number," in types diff --git a/resources/tester/test_266_detail_home.py b/resources/tester/test_266_detail_home.py new file mode 100644 index 000000000..31dcecc2f --- /dev/null +++ b/resources/tester/test_266_detail_home.py @@ -0,0 +1,85 @@ +"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것). + +짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) · +`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`. +실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_266_detail_home.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_가로_반폭_15(out): + # 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15 + case = out["global15"] + assert case["widened"] is True and case["homeScale"] > 1 + assert case["first"] == [-15, 15] + + +def test_개별_반폭_측점(out): + assert out["station10"]["first"] == [-10, 10] + + +def test_안_넓힌_칸_그대로(out): + case = out["tall15"] + assert case["widened"] is False and case["homeScale"] == 1 + assert case["first"] == [-15, 15] + + +@pytest.mark.parametrize("name", ["global15", "station10", "tall15"]) +def test_처음으로_같은_범위_줌_이동_그대로(out, name): + case = out[name] + assert case["reset"] == case["first"] # [⤢] + assert case["dblclick"] == case["first"] # 더블클릭 + assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌 + assert case["panned"] is True # 가운데 단추 끌기 + assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지 + + +def test_배선(): + view = _read("B06_Section_UI_Section_View.ts") + assert ( + "const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;" + in view + ) + assert "return { svg, axes, homeScale };" in view + # 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길) + assert ( + "effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)" + in view + ) + src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) + assert "drawn.homeScale" in src and "homeScale, onChange" in src + assert "homeScale: drawn.homeScale" in src From d909d6aabedb5634b23fdfe6b7b7e4722e530097 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:36:35 +0900 Subject: [PATCH 069/151] =?UTF-8?q?feat(B09):=20263-1=20=EC=9A=B4=EB=B0=98?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=20=EB=AA=A8=EB=8B=AC=20=3D=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=ED=95=AD=EB=AA=A9=20=EC=9E=85=EB=A0=A5=EB=A7=8C=20?= =?UTF-8?q?+=20=EA=B1=B0=EB=A6=AC=20=EC=9E=90=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사이클시간 입력 · 8-2-8 표 고르기 칸 없앰 · 사이클 = 8-2-8 기본값(계산 절차에만) - 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 · 거리 칸 없는 항목 = 사이클 줄 없음 - [변경] = 단가연결 한 목록(onPick) · [다른 종류] 없앰 · 조합 = [적용] 때 사본 들여옴 · 호표 = 그대로 연결 - 옛 cycle = 서버 읽기 그대로 · [적용]하면 지움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_UI_Milestone.ts | 2 - .../B09_Estimation_UI_Haul_Modal.ts | 263 +++++++----------- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 54 +++- resources/tester/test_251_1_haul_groups.py | 2 +- resources/tester/test_253_3_haul_preview.py | 4 +- resources/tester/test_263_1_haul_modal.py | 89 ++++++ 6 files changed, 228 insertions(+), 186 deletions(-) create mode 100644 resources/tester/test_263_1_haul_modal.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index ca61bc6dc..2d524c63f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -20,7 +20,6 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; -import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { @@ -163,7 +162,6 @@ function tableView(projectId: string): MilestoneView { dist: at && { L1: at.L1, L2: at.L2 }, name: route.rows[at?.index ?? 0]?.item || route.name || "운반", apply: link, - other: () => void openPriceLink({ apply: link }), }), ], ...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []), diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts index e5291bed2..8dd19e62c 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -1,13 +1,13 @@ /* ============================================================================= * B09_Estimation_UI_Haul_Modal.ts - * 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. - * 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) · - * 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) · - * 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄 - * (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). - * 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · - * 빈칸으로 지우면 다시 거리에서. - * 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). + * 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. + * 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) · + * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음) · + * 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) + + * 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). + * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · + * 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). + * 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결. * ========================================================================== */ @@ -22,65 +22,28 @@ import { fillGiven, type Dist, } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; -import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note"; import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; -import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { + importLib, + openPriceLink, + type Picked, + type PriceLinkOpts, +} from "./B09_Estimation_UI_Tabs_Bill_Link"; -type Apply = (ref: Row | null, from?: Row) => void; -export interface HaulModalOpts { +export interface HaulModalOpts extends PriceLinkOpts { /** 지금 연결(없으면 null) */ ref: Row | null; /** 그 행 거리 — L1 포장 계 · L2 비포장(km) */ dist: Dist; /** 줄 이름(제목) */ name: string; - apply: Apply; - /** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */ - other?: () => void; } -/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */ -const CYCLE_FIELDS: Array<[string, string, string][]> = [ - [ - ["L1", "포장 운반거리 L1", "km"], - ["L2", "비포장 운반거리 L2", "km"], - ], - [ - ["V1", "포장 적재 V1", "km/hr"], - ["V2", "포장 공차 V2", "km/hr"], - ["V3", "비포장 적재 V3", "km/hr"], - ["V4", "비포장 공차 V4", "km/hr"], - ], - [ - ["t1", "적재 t1", "분"], - ["T", "적재량 T", "ton"], - ["rt", "단위중량 rt", "t/㎥"], - ["f", "체적환산 f", ""], - ["qs", "버킷용량 qs", "㎥"], - ["k", "버킷계수 k", ""], - ["Es", "작업효율 Es", ""], - ["Cms", "적재기계 Cms", "초"], - ], - [ - ["t3", "적하 t3", "분"], - ["t4", "대기 t4", "분"], - ["t5", "덮개 t5", "분"], - ["t6", "세륜 t6", "분"], - ], -]; -/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */ -const PICKS: Array<[string, Record, string]> = [ - ["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"], - ["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"], - ["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"], - ["CC000056", { 시간: "t4" }, "대기(적재장소)"], - ["CC000057", { 시간: "t5" }, "덮개"], - ["CC000058", { 시간: "t6" }, "세륜"], -]; -const OWNABLE = new Set(["L1", "L2", "t1"]); +/** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */ +const OLD_OWN = new Set(["L1", "L2", "t1"]); const FROM_DIST = "거리에서"; const fmt = (v: number | null | undefined): string => @@ -91,15 +54,11 @@ export function cycleLines(input: Record): string[] { const v: Record = { ...CYCLE_DEFAULTS }; for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; const r = cycleTime(input); - const t1 = - typeof input.t1 === "number" - ? `t1 = ${fmt(r.t1)} 분(손 입력)` - : `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`; return [ "사이클시간 — 건설품셈 8-2-8 덤프트럭", `q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`, `n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`, - t1, + `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`, `t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`, `Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`, `운반거리 = L1 + L2 = ${r.L} km`, @@ -127,21 +86,33 @@ async function preview(code: string, given: Row, rate: unknown, picks: unknown): } const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text); +const KIND_TEXT: Record = { + logic: "단가산출", + precombo: "일위대가 사전조합", + combo: "구조물 조합", + dg: "일위대가 호표", + sg: "단가산출 호표", +}; /** 「운반 단가」 모달 */ export function openHaulModal(o: HaulModalOpts): void { const logicRef = o.ref?.tab === "logic" ? o.ref : null; + const old = (logicRef?.cycle as Row) || {}; const st = { + /** 고른 것 — 단가산출 아닌 것(조합 · 호표) */ + other: (o.ref && !logicRef + ? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } } + : null) as Picked | null, code: String(logicRef?.code ?? ""), name: String(logicRef?.name ?? ""), given: { ...((logicRef?.given as Row) || {}) } as Row, - cycle: { ...((logicRef?.cycle as Row) || {}) } as Row, - own: new Set((logicRef?.own as string[]) || []), + own: new Set(((logicRef?.own as string[]) || []).filter((n) => !OLD_OWN.has(n))), rate: logicRef?.rate ?? null, picks: logicRef?.picks ?? null, inputs: [] as ItemInput[], unit: "", }; + const oldCycle = Object.keys(old).length > 0; let seq = 0; let timer = 0; openModal({ @@ -159,65 +130,46 @@ export function openHaulModal(o: HaulModalOpts): void { attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" }, }); const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] }); - const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given }); + // 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움) + const ref = (): Row => ({ own: [...st.own], cycle: {}, given: st.given }); const names = (): string[] => st.inputs.map((i) => i.이름); const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; const redraw = (): void => { + const fills = st.code !== "" && names().some((n) => n in CYCLE_FILL); const input = cycleInputs(ref(), o.dist); - const head2 = cycleLines(input); - steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join( - "\n", - ); + const head2 = fills + ? [ + `거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`, + ...cycleLines(input), + "", + ] + : []; window.clearTimeout(timer); - if (!st.code) return; const my = ++seq; + if (!st.code) { + const what = st.other + ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` + : "없음"; + steps.textContent = `연결 — ${what}`; + return; + } + steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n"); timer = window.setTimeout(() => { preview(st.code, filled(), st.rate, st.picks).then( (got) => { if (my !== seq) return; // 최신 응답만 st.unit = got.unit; const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`; - steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n"); + steps.textContent = [...head2, ...got.lines, "", `단가 = ${price}`].join("\n"); }, (error: unknown) => { - if (my === seq) - steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n"); + if (my === seq) steps.textContent = [...head2, `못 셈 — ${String(error)}`].join("\n"); }, ); }, 250); }; - const numberField = (name: string, text: string, unit: string): HTMLElement => { - const input = el("input", { - className: "ui-input m02-inputval__control", - attrs: { type: "number", step: "any", "data-haul-cycle": name }, - }) as HTMLInputElement; - const tag = el("span", { className: "m02-labin__muted" }); - const show = (): void => { - const now = cycleInputs(ref(), o.dist); - const auto = - name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]); - const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle; - if (document.activeElement !== input) - input.value = mine ? String(st.cycle[name] ?? "") : ""; - input.placeholder = String(auto ?? ""); - tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : ""; - }; - input.addEventListener("change", () => { - const v = input.value.trim() === "" ? null : Number(input.value); - if (v === null || !Number.isFinite(v)) { - delete st.cycle[name]; - st.own.delete(name); - } else { - st.cycle[name] = v; - if (OWNABLE.has(name)) st.own.add(name); - } - update(); - }); - shows.push(show); - return row(label(text, unit), [input, tag]); - }; const row = (text: string, cells: HTMLElement[]): HTMLElement => el("div", { className: "m02-inputval__row", @@ -228,33 +180,6 @@ export function openHaulModal(o: HaulModalOpts): void { }); const shows: Array<() => void> = []; - const tablePick = (key: string, map: Record, text: string): HTMLElement => { - const select = el("select", { - className: "ui-select ui-select--sm m02-inputval__control", - attrs: { "data-haul-pick": Object.values(map).join(",") }, - }) as HTMLSelectElement; - select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } })); - void loadTable(key).then((t: TableRow | null) => { - const conds = Object.keys(t?.조건 || {}); - (t?.줄 || []).forEach((line, i) => { - const what = conds.map((c) => String(line[c] ?? "")).join(" · "); - const vals = Object.keys(map) - .map((col) => `${map[col]} ${String(line[col] ?? "")}`) - .join(" · "); - select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } })); - }); - select.onchange = () => { - const line = t?.줄?.[Number(select.value)]; - if (!line) return; - for (const [col, name] of Object.entries(map)) - if (typeof line[col] === "number") st.cycle[name] = line[col] as number; - select.value = ""; - update(); - }; - }); - return row(text, [select]); - }; - const logicRows = (): HTMLElement[] => st.inputs.map((input) => { const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름); @@ -291,29 +216,36 @@ export function openHaulModal(o: HaulModalOpts): void { shows.forEach((show) => show()); redraw(); }; - /** 로직 바뀜 — 왼쪽 칸을 새로 */ + const muted = (text: string): HTMLElement => el("p", { className: "m02-labin__muted", text }); + /** 고른 것 바뀜 — 왼쪽 칸을 새로 */ const refresh = (): void => { shows.splice(0); - const sec = (text: string): HTMLElement => - el("h4", { text, attrs: { style: "margin:12px 0 4px" } }); - left.replaceChildren( - sec("단가산출 옵션 값"), - ...(st.code + const lines = st.code + ? st.inputs.length ? logicRows() - : [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]), - sec("사이클시간 입력"), - ...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))), - sec("표에서 고르기(건설품셈 8-2-8)"), - ...PICKS.map(([key, map, text]) => tablePick(key, map, text)), + : [muted("입력 없음")] + : [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")]; + left.replaceChildren( + el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }), + ...lines, + ...(oldCycle && st.code + ? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")] + : []), ); - title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음"; - change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기"; + const p = st.other; + title.textContent = st.code + ? `단가산출 ${st.code} ${st.name}` + : p + ? `${KIND_TEXT[p.kind] ?? p.kind} ${String("from" in p ? (p.from.name ?? "") : "name" in p ? p.name : "")}` + : "연결 — 없음"; + change.querySelector(".ui-btn__label")!.textContent = st.code || p ? "변경" : "고르기"; update(); }; const useLogic = (code: string, name: string): void => { fetchLogicInputs([code]).then( (got) => { + st.other = null; st.code = code; st.name = name; st.inputs = got.로직[0]?.입력 ?? []; @@ -324,41 +256,40 @@ export function openHaulModal(o: HaulModalOpts): void { (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), ); }; + const usePicked = (p: Picked): void => { + if (p.kind === "logic") return useLogic(p.code, p.name); + st.other = p; + st.code = ""; + st.inputs = []; + refresh(); + }; const change = createButton({ label: "변경", variant: "ghost", size: "sm", - onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)), + onClick: () => void openPriceLink({ ...o, onPick: usePicked }), }); linkBox.append(title, change); - if (o.other) - linkBox.append( - createButton({ - label: "다른 종류", - variant: "ghost", - size: "sm", - onClick: () => { - close(); - o.other?.(); - }, - }), - ); const ok = createButton({ label: "적용", onClick: () => { - if (!st.code) return showToast("단가산출을 먼저 고르세요", "error"); - const next: Row = { - tab: "logic", - code: st.code, - name: st.name, - given: filled(), - rate: st.rate, - picks: st.picks, - cycle: st.cycle, - own: [...st.own], - }; - o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") }); - showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success"); + const p = st.other; + if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error"); + if (p && (p.kind === "combo" || p.kind === "precombo")) void importLib(o, p); + else if (p && "ref" in p) o.apply(p.ref, p.from); + else { + const next: Row = { + tab: "logic", + code: st.code, + name: st.name, + given: filled(), + rate: st.rate, + picks: st.picks, + own: [...st.own], + }; + o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") }); + showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success"); + } close(); }, }); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 5831e654c..20a4bf31a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -61,13 +61,26 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row ); } +/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) */ +export type LibPick = { + kind: "combo" | "precombo"; + layer: string; + owner: string | null; + key: string; +}; +export type Picked = + | { kind: "logic"; code: string; name: string } + | (LibPick & { name: string; unit: string }) + | { kind: "dg" | "sg"; ref: Row; from: Row }; + /** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만 * 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */ -async function importLib( +export async function importLib( o: { apply: ApplyRef; onPayload?: (payload: Row) => void }, - pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string }, + lib: LibPick, ): Promise { const apply = o.apply; + const pick = { kind: lib.kind, layer: lib.layer, owner: lib.owner, key: lib.key }; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return; showToast("가져오는 중…", "info"); @@ -98,7 +111,7 @@ async function importLib( } /** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 · - * haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달(옛 창은 [다른 종류]) */ + * haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달([변경] = 같은 목록 · 263-1) */ export function linkItems( ctx: TabContext, ref: Row | null, @@ -106,14 +119,14 @@ export function linkItems( haul?: { dist: Dist; name: string }, ): [Leaf[], Leaf[]] { const more: Leaf[] = []; - const priceLink = (): void => - void openPriceLink({ - apply, - hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, - onPayload: (p) => Object.assign(ctx.payload, p), - }); + const link: PriceLinkOpts = { + apply, + hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, + onPayload: (p) => Object.assign(ctx.payload, p), + }; + const priceLink = (): void => void openPriceLink(link); const haulModal = (): void => - openHaulModal({ ref, dist: haul?.dist, name: haul?.name || "", apply, other: priceLink }); + openHaulModal({ ...link, ref, dist: haul?.dist, name: haul?.name || "" }); if (ref?.tab === "logic" && !haul) more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]); if (ref) more.push(["단가연결 풀기", () => apply(null)]); @@ -129,9 +142,18 @@ export interface PriceLinkOpts { hopyo?: { dg: Row[]; sg: Row[] }; /** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */ onPayload?: (payload: Row) => void; + /** 고른 것 받기(263-1 「운반 단가」 모달) — 없으면 단가산출 = 옵션 값 창 · 조합 = 사본 들여옴 · 호표 = 연결 */ + onPick?: (picked: Picked) => void; } export async function openPriceLink(o: PriceLinkOpts): Promise { + const pick = + o.onPick ?? + ((p: Picked): void => { + if (p.kind === "logic") askInputs(o.apply, p.code, p.name); + else if ("ref" in p) o.apply(p.ref, p.from); + else void importLib(o, p); + }); const hop = (tab: "dg" | "sg") => ((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({ kind: tab, @@ -140,7 +162,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise { name: String(r.name || ""), sub: String(r.spec || ""), unit: String(r.unit || ""), - onPick: () => o.apply({ tab, key: r.key }, r), + onPick: () => pick({ kind: tab, ref: { tab, key: r.key }, from: r }), })); // 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천) const combos = (await loadRows().catch(() => [])).map((r) => ({ @@ -151,14 +173,16 @@ export async function openPriceLink(o: PriceLinkOpts): Promise { sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`, unit: String(r.단위 || ""), onPick: () => - void importLib(o, { + pick({ kind: "combo", layer: r.layer ?? "system", owner: r.owner ?? null, key: r.key, + name: itemName(r), + unit: String(r.단위 || ""), }), })); - openLogicPicker((logic) => askInputs(o.apply, logic.키, logic.이름), { + openLogicPicker((logic) => pick({ kind: "logic", code: logic.키, name: logic.이름 }), { layer: "personal", owner: null, title: "단가연결", @@ -174,7 +198,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise { ] : []), ], - pick: (ref) => - void importLib(o, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), + pick: (ref, name) => + pick({ kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키, name, unit: "" }), }); } diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py index e7fc2efd1..3ae95ee38 100644 --- a/resources/tester/test_251_1_haul_groups.py +++ b/resources/tester/test_251_1_haul_groups.py @@ -139,6 +139,6 @@ console.log(JSON.stringify([refText(null), ] # 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것 assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src - assert "openPriceLink({ apply: link })" in src and '"단가연결 풀기"' in src + assert "openHaulModal({" in src and '"단가연결 풀기"' in src # 263-1 = 운반 단가 모달 하나 diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8") assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음) diff --git a/resources/tester/test_253_3_haul_preview.py b/resources/tester/test_253_3_haul_preview.py index 730812d0c..aa8febe95 100644 --- a/resources/tester/test_253_3_haul_preview.py +++ b/resources/tester/test_253_3_haul_preview.py @@ -48,8 +48,8 @@ def test_내역_줄_거리(): def test_모달_여는_곳(): modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") - for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven", - "cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip + for want in ("/estimation/logic-preview", "openPriceLink", "fetchLogicInputs", "fillGiven", + "cycleInputs", "my !== seq", "거리에서", "[고르기]"): # fmt: skip assert want in modal, want milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert "openHaulModal({" in milestone and "L1: paved" in milestone diff --git a/resources/tester/test_263_1_haul_modal.py b/resources/tester/test_263_1_haul_modal.py new file mode 100644 index 000000000..8df0d54f4 --- /dev/null +++ b/resources/tester/test_263_1_haul_modal.py @@ -0,0 +1,89 @@ +"""263-1 「운반 단가」 모달 = 고른 항목 입력만 + 거리 자동 — 사이클 손 칸 · 8-2-8 표 고르기 없음 · +[변경] = 단가연결 한 목록 · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 · +옛 `cycle` = 서버 읽기 그대로(모달 [적용]하면 지움).""" + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import _input_names, hand_sources # noqa: E402 + +DIST = {"L1": 23.8, "L2": 10.5} + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_왼쪽_칸_고른_로직_입력만(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + for gone in ("data-haul-cycle", "CC000054", "사이클시간 입력", "표에서 고르기", "다른 종류", + "numberField", "loadTable"): # fmt: skip + assert gone not in modal, gone + # 칸 = 로직 입력 하나에 한 줄(createValueControl) · 사이클 줄 = 거리 칸 있을 때만 + assert "st.inputs.map((input) =>" in modal and "createValueControl(" in modal + assert "names().some((n) => n in CYCLE_FILL)" in modal + assert "포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km" in modal + # 거리 받는 마스터 로직 = 거리 칸 하나씩 + for code, want in (("GF000284", "운반거리"), ("GF000134", "Cm"), ("GF000283", "사이클시간")): + names = _input_names(code) + assert [n for n in names if n in FILL] == [want], (code, names) + + +def test_변경_한_목록(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, p)" in modal + link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") + assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link + milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "other:" not in milestone and "openPriceLink" not in milestone + + +def test_거리_칸_자동_손_값_이김(): + ref = {"given": {"재료": "토사"}, "cycle": {}, "own": []} + got = fill_given(ref, _input_names("GF000284"), DIST) + assert got["운반거리"] == 34.3 # L1 + L2 + cm = cycle_time(DIST)["cm"] + assert fill_given(ref, ["Cm"], DIST)["Cm"] == cm + assert fill_given(ref, ["사이클시간", "t2"], DIST) == { + "재료": "토사", "사이클시간": cm, "t2": cycle_time(DIST)["t2"]} # fmt: skip + mine = {"given": {"운반거리": 5}, "own": ["운반거리"]} + assert fill_given(mine, ["운반거리"], DIST)["운반거리"] == 5 + + +def test_ts_짝_사이클_비움(): + """모달 = cycle {} · 옛 own L1 · L2 · t1 뺌 → TS fillGiven = 파이썬과 같음.""" + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + ref = {"given": {"qt": 15}, "cycle": {}, "own": []} + body = ( + f"\nconsole.log(JSON.stringify(fillGiven({json.dumps(ref)}, ['Cm', '운반거리']," + f" {json.dumps(DIST)})));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == fill_given(ref, ["Cm", "운반거리"], DIST) + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert ( + "cycle: {}, given: st.given" in modal and 'OLD_OWN = new Set(["L1", "L2", "t1"])' in modal + ) + assert "cycle: st.cycle" not in modal # [적용] 새 연결에 cycle 없음 + + +def test_옛_cycle_서버_읽기_그대로(): + old = {"tab": "logic", "code": "GF000134", "given": {"Cm": 1}, "cycle": {"V1": 20}} + user = {"haul_refs": {"haul:r-u1": old}, "haul_dist": {"haul:r-u1": DIST}} + ((_k, _u, src),) = hand_sources(user) + assert src["given"]["Cm"] == cycle_time({**DIST, "V1": 20})["cm"] + new = {**old, "cycle": None} + user["haul_refs"]["haul:r-u1"] = new + ((_k, _u, src),) = hand_sources(user) + assert src["given"]["Cm"] == cycle_time(DIST)["cm"] From fe2ed662680122cf6bc2f73db84e79109b7c67bc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:40:21 +0900 Subject: [PATCH 070/151] =?UTF-8?q?feat(B09):=20264-2=20=EA=B3=B5=EA=B8=B0?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=85=88=20=EC=A7=9D=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EA=B8=B0=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 셈 짝(Py · TS) 새 옵션 — 착공일(그날부터 · A 날수 비례) · 기후 자료 고르기 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준(장비 시간 합) · 중복 C 끝수 · 마지막 달 비작업 - 원천 = 기후 셋(거창 25 · 울진 24 · 울진 소광 25 · 0에서 직접) · 단위당 장비 시간 합 - clean_schedule 새 칸(모르는 값 · 범위 밖 버림) - 시험 test_264_2 — 기본값 = 지금 결과 · 옵션마다 값 바뀜 · 거울 TS = Py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../draft/B09_Draft_Schedule_Calc.py | 127 ++++++++--- .../draft/B09_Draft_Schedule_Calc.ts | 140 +++++++++--- .../draft/B09_Draft_Schedule_Source.py | 76 ++++++- .../tester/test_264_2_b09_schedule_options.py | 210 ++++++++++++++++++ 4 files changed, 476 insertions(+), 77 deletions(-) create mode 100644 resources/tester/test_264_2_b09_schedule_options.py diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py index defcf5af0..7dd55204f 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py @@ -1,6 +1,6 @@ """B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). -짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`). +짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · `test_264_2_b09_schedule_options.py`). 근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만: 공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) @@ -11,13 +11,19 @@ 단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위) 반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0 소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0) - 공기집계 착공 연월부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) · + 공기집계 착공일부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) · 법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) · 중복 C = A × B ÷ 달력 · 비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업 준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 · 마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0) - 공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공(그 달 1일) + 공사기간 − 1 + 공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공일 + 공사기간 − 1 + 옵션(264 · user.schedule · 기본값 = 위 식 그대로) — 착공일(1일 아니면 첫 달 = 그날부터 · + A = 날수 비례) · 기후 자료(원천 climates 고르기 · 달마다 덮개가 이김) · + 쉬는 날(일 · 토일) · 공휴일 빼기(대체 · 선거일 · 노동절 · 제헌절 — 이름으로 가름) · + 투입 기본(장비 대 · 인력 인) · 반영률 기본(운반공 · 그 밖) · + 주 장비 기준(가장 긴 장비 · 장비 시간 합) · 중복 C 끝수(그대로 · 소수 1자리 반올림) · + 마지막 달 비작업(쓴 비율 올림 · 전부) """ from __future__ import annotations @@ -27,12 +33,26 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup -HOURS = 8 -CREW = 10 -PREP_DOCS = 2 -PREP_SETUP = 2 -TIDY = 4 -#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 0(운반 = 다른 공종과 겹침) +#: 사용자 값 기본(264 패널 · 표 칸 같은 값) +DEFAULTS = { + "hours": 8, + "units": 1, + "crew": 10, + "prep_docs": 2, + "prep_setup": 2, + "tidy": 4, + "etc": 0, + "rate_haul": 0, + "rate_base": 100, +} +#: 공휴일 빼기 갈래 — 이름에 든 글 +HOLIDAY_KINDS = { + "substitute": ("대체",), + "election": ("선거",), + "labor": ("노동절", "근로자"), + "constitution": ("제헌절",), +} +#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 운반공(= 다른 공종과 겹침) SKIP_TOP = ("4", "G") OVERLAP_TOP = ("3",) MAX_MONTHS = 120 @@ -42,18 +62,23 @@ def _top(section: Any) -> str: return str(section or "").split(".")[0] -def _cfg(user: dict[str, Any], key: str, default: float) -> float: +def _cfg(user: dict[str, Any], key: str) -> float: value = num(user.get(key)) - return default if value is None or value < 0 else value + return DEFAULTS[key] if value is None or value < 0 else value -def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hours: float) -> dict: +def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, base: dict) -> dict: + hours = base["hours"] qty = num(item.get("qty")) or 0.0 rate = num(cfg.get("rate")) if rate is None: - rate = 0.0 if _top(item.get("section")) in OVERLAP_TOP else 100.0 + rate = base["rate_haul"] if _top(item.get("section")) in OVERLAP_TOP else base["rate_base"] daily_in = num(cfg.get("daily")) machine = (unit or {}).get("machine") + total = (unit or {}).get("machine_sum") + if machine and base["sum"] and total and (num(total.get("hr")) or 0) > 0: + many = f" 외 {total['n'] - 1}종" if (total.get("n") or 1) > 1 else "" + machine = {**machine, "name": f"{machine.get('name') or ''}{many}", "hr": total["hr"]} labor = num((unit or {}).get("labor")) out = { "kind": "item", @@ -78,12 +103,12 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour need = qty * rate / 100 / daily_in elif machine and (num(machine.get("hr")) or 0) > 0: hr = num(machine["hr"]) - units = num(cfg.get("units")) or 1.0 + units = num(cfg.get("units")) or base["units"] out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units) out.update(q=1 / hr, daily=hours * units / hr) need = qty * rate / 100 * hr / (hours * units) elif labor is not None and labor > 0: - crew = num(cfg.get("crew")) or float(CREW) + crew = num(cfg.get("crew")) or base["crew"] out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor) need = qty * rate / 100 * labor / crew else: @@ -96,7 +121,15 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict: """공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리.""" - hours = _cfg(user, "hours", HOURS) or HOURS + hours = _cfg(user, "hours") or DEFAULTS["hours"] + base = { + "hours": hours, + "units": _cfg(user, "units") or DEFAULTS["units"], + "crew": _cfg(user, "crew") or DEFAULTS["crew"], + "rate_haul": min(_cfg(user, "rate_haul"), 100), + "rate_base": min(_cfg(user, "rate_base"), 100), + "sum": user.get("machine_basis") == "sum", + } units = src.get("units") or {} cfgs = user.get("rows") or {} rows: list[dict[str, Any]] = [] @@ -129,11 +162,11 @@ def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) "lv": head.get("level") or 1, } # fmt: skip ) - rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, hours)) - docs = _cfg(user, "prep_docs", PREP_DOCS) - setup = _cfg(user, "prep_setup", PREP_SETUP) - etc = _cfg(user, "etc", 0) - tidy = _cfg(user, "tidy", TIDY) + rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, base)) + docs = _cfg(user, "prep_docs") + setup = _cfg(user, "prep_setup") + etc = _cfg(user, "etc") + tidy = _cfg(user, "tidy") days = sum(r["days"] or 0 for r in rows if r["kind"] == "item") subtotal = round_half(days + etc, 0) missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None) @@ -156,45 +189,70 @@ def _month_days(year: int, month: int) -> int: return (nxt - datetime.date(year, month, 1)).days -def _holidays(src: dict[str, Any], year: int, month: int) -> tuple[int, list[str]]: - """그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" +def _counted(name: str, off: list[str]) -> bool: + """공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음.""" + for part in name.split(";"): + kinds = [k for k, words in HOLIDAY_KINDS.items() if any(w in part for w in words)] + if not any(k in off for k in kinds): + return True + return False + + +def _holidays( + src: dict[str, Any], year: int, month: int, first: int, user: dict[str, Any] +) -> tuple[int, list[str]]: + """그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" head = f"{year:04d}-{month:02d}-" + off = user.get("holiday_off") or [] named = { - d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head) + d: n + for d, n in (src.get("holidays") or {}).get(str(year)) or [] + if d.startswith(head) and int(d[-2:]) >= first and _counted(n, off) } + rest = (5, 6) if user.get("rest") == "satsun" else (6,) count = 0 - for day in range(1, _month_days(year, month) + 1): + for day in range(first, _month_days(year, month) + 1): iso = f"{head}{day:02d}" - if datetime.date(year, month, day).weekday() == 6 or iso in named: + if datetime.date(year, month, day).weekday() in rest or iso in named: count += 1 return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())] +def climate_base(src: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: + """고른 기후 자료(원천 climates) · 없으면 원천 기본값.""" + return (src.get("climates") or {}).get(user.get("climate_set")) or src.get("climate") or {} + + def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float: mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1] if num(mine) is not None: return num(mine) - return num(((src.get("climate") or {}).get(kind) or [0] * 12)[month - 1]) or 0.0 + return num((climate_base(src, user).get(kind) or [0] * 12)[month - 1]) or 0.0 def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]: - """공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움.""" + """공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움.""" start = {**(src.get("start") or {}), **(user.get("start") or {})} year, month = int(start.get("year") or 2000), int(start.get("month") or 1) + first = min(max(int(start.get("day") or 1), 1), _month_days(year, month)) + begin = datetime.date(year, month, first) left = [max(p, 0) for p in parts] months: list[dict[str, Any]] = [] total = 0 while sum(left) > 0 or not months: if len(months) >= MAX_MONTHS: break - days = _month_days(year, month) + full = _month_days(year, month) + days = full - first + 1 rain = _climate(src, user, "rain", month) heat = _climate(src, user, "heat", month) - a = roundup(rain + heat, 1) - auto, names = _holidays(src, year, month) + a = roundup(rain + heat if first == 1 else (rain + heat) * days / full, 1) + auto, names = _holidays(src, year, month, first, user) mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}")) b = mine if mine is not None else auto c = a * b / days + if user.get("c_round") == "round1": + c = round_half(c, 1) off = rounddown(a + b - c, 0) avail = days - off cap = max(avail, 0) @@ -208,7 +266,7 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) - used = sum(use) if cap <= 0: off_used = days - elif used >= cap: + elif used >= cap or user.get("last_off") == "all": off_used = off else: off_used = roundup(off * used / cap, 0) @@ -237,11 +295,10 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) - } # fmt: skip ) year, month = (year + 1, 1) if month == 12 else (year, month + 1) - first = months[0] - begin = datetime.date(first["year"], first["month"], 1) + first = 1 end = begin + datetime.timedelta(days=max(total, 1) - 1) return { - "start": {"year": first["year"], "month": first["month"]}, + "start": {"year": begin.year, "month": begin.month, "day": begin.day}, "months": months, "days": total, "months_n": round_half(total / 30, 1), diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts index 0ae43dce9..501a98915 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts @@ -1,35 +1,59 @@ /* ============================================================================= * B09_Draft_Schedule_Calc.ts * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). - * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리. + * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · + * `test_264_2_b09_schedule_options.py`) — 식 설명은 그쪽 머리. * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 - * 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * 공기집계 = 착공일부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * 옵션(264) = 착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준 · + * 중복 C 끝수 · 마지막 달 비작업(기본값 = 위 식 그대로) * ========================================================================== */ import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; -const HOURS = 8; -const CREW = 10; -const PREP_DOCS = 2; -const PREP_SETUP = 2; -const TIDY = 4; +/** 사용자 값 기본(264 패널 · 표 칸 같은 값) */ +export const DEFAULTS: Record = { + hours: 8, + units: 1, + crew: 10, + prep_docs: 2, + prep_setup: 2, + tidy: 4, + etc: 0, + rate_haul: 0, + rate_base: 100, +}; +/** 공휴일 빼기 갈래 — 이름에 든 글 */ +export const HOLIDAY_KINDS: Record = { + substitute: ["대체"], + election: ["선거"], + labor: ["노동절", "근로자"], + constitution: ["제헌절"], +}; const SKIP_TOP = ["4", "G"]; const OVERLAP_TOP = ["3"]; const MAX_MONTHS = 120; const top = (section: unknown): string => String(section ?? "").split(".")[0]; -function cfg(user: Row, key: string, fallback: number): number { +function cfg(user: Row, key: string): number { const value = num(user[key]); - return value === null || value < 0 ? fallback : value; + return value === null || value < 0 ? DEFAULTS[key] : value; } -function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { +function workRow(item: Row, unit: Row | undefined, c: Row, base: Row): Row { + const hours: number = base.hours; const qty = num(item.qty) ?? 0; let rate = num(c.rate); - if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100; + if (rate === null) + rate = OVERLAP_TOP.includes(top(item.section)) ? base.rate_haul : base.rate_base; const dailyIn = num(c.daily); - const machine = unit?.machine as Row | null | undefined; + let machine = unit?.machine as Row | null | undefined; + const total = unit?.machine_sum as Row | null | undefined; + if (machine && base.sum && total && (num(total.hr) ?? 0) > 0) { + const many = (total.n || 1) > 1 ? ` 외 ${total.n - 1}종` : ""; + machine = { ...machine, name: `${machine.name || ""}${many}`, hr: total.hr }; + } const labor = num(unit?.labor); const out: Row = { kind: "item", @@ -55,12 +79,12 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { need = (qty * rate) / 100 / dailyIn; } else if (machine && (num(machine.hr) ?? 0) > 0) { const hr = num(machine.hr) as number; - const units = num(c.units) || 1; + const units = num(c.units) || base.units; Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units }); Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr }); need = (((qty * rate) / 100) * hr) / (hours * units); } else if (labor !== null && labor > 0) { - const crew = num(c.crew) || CREW; + const crew = num(c.crew) || base.crew; Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor }); need = (((qty * rate) / 100) * labor) / crew; } else { @@ -74,7 +98,15 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { /** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */ export function workTable(src: Row, billRows: Row[], user: Row): Row { - const hours = cfg(user, "hours", HOURS) || HOURS; + const hours = cfg(user, "hours") || DEFAULTS.hours; + const base: Row = { + hours, + units: cfg(user, "units") || DEFAULTS.units, + crew: cfg(user, "crew") || DEFAULTS.crew, + rate_haul: Math.min(cfg(user, "rate_haul"), 100), + rate_base: Math.min(cfg(user, "rate_base"), 100), + sum: user.machine_basis === "sum", + }; const units: Row = src.units || {}; const cfgs: Row = user.rows || {}; const rows: Row[] = []; @@ -101,12 +133,12 @@ export function workTable(src: Row, billRows: Row[], user: Row): Row { p[1] = true; rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 }); } - rows.push(workRow(row, units[key], cfgs[key] || {}, hours)); + rows.push(workRow(row, units[key], cfgs[key] || {}, base)); } - const docs = cfg(user, "prep_docs", PREP_DOCS); - const setup = cfg(user, "prep_setup", PREP_SETUP); - const etc = cfg(user, "etc", 0); - const tidy = cfg(user, "tidy", TIDY); + const docs = cfg(user, "prep_docs"); + const setup = cfg(user, "prep_setup"); + const etc = cfg(user, "etc"); + const tidy = cfg(user, "tidy"); const items = rows.filter((r) => r.kind === "item"); const days = items.reduce((s: number, r) => s + (r.days || 0), 0); const subtotal = roundHalf(days + etc, 0); @@ -129,16 +161,38 @@ const monthDays = (year: number, month: number): number => const pad = (n: number): string => String(n).padStart(2, "0"); -/** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ -function holidays(src: Row, year: number, month: number): [number, string[]] { +/** 공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음 */ +function counted(name: string, off: string[]): boolean { + for (const part of name.split(";")) { + const kinds = Object.keys(HOLIDAY_KINDS).filter((k) => + HOLIDAY_KINDS[k].some((w) => part.includes(w)), + ); + if (!kinds.some((k) => off.includes(k))) return true; + } + return false; +} + +/** 그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ +function holidays( + src: Row, + year: number, + month: number, + first: number, + user: Row, +): [number, string[]] { const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; + const off: string[] = user.holiday_off || []; const named = new Map( - ((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)), + ((src.holidays || {})[String(year)] || []).filter( + ([d, n]: [string, string]) => + d.startsWith(head) && Number(d.slice(-2)) >= first && counted(n, off), + ), ); + const rest = user.rest === "satsun" ? [6, 0] : [0]; let count = 0; - for (let day = 1; day <= monthDays(year, month); day += 1) { - const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0; - if (sunday || named.has(`${head}${pad(day)}`)) count += 1; + for (let day = first; day <= monthDays(year, month); day += 1) { + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + if (rest.includes(weekday) || named.has(`${head}${pad(day)}`)) count += 1; } const names = [...named.entries()] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) @@ -146,30 +200,38 @@ function holidays(src: Row, year: number, month: number): [number, string[]] { return [count, names]; } +/** 고른 기후 자료(원천 climates) · 없으면 원천 기본값 */ +export const climateBase = (src: Row, user: Row): Row => + (src.climates || {})[user.climate_set] || src.climate || {}; + function climate(src: Row, user: Row, kind: string, month: number): number { const mine = num(((user.climate || {})[kind] || [])[month - 1]); if (mine !== null) return mine; - return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0; + return num((climateBase(src, user)[kind] || [])[month - 1]) ?? 0; } -/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */ +/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움 */ export function monthTable(src: Row, user: Row, parts: number[]): Row { const start: Row = { ...(src.start || {}), ...(user.start || {}) }; let year = Math.trunc(Number(start.year) || 2000); let month = Math.trunc(Number(start.month) || 1); + let first = Math.min(Math.max(Math.trunc(Number(start.day) || 1), 1), monthDays(year, month)); + const begin = Date.UTC(year, month - 1, first); const left = parts.map((p) => Math.max(p, 0)); const months: Row[] = []; let total = 0; while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) { if (months.length >= MAX_MONTHS) break; - const days = monthDays(year, month); + const full = monthDays(year, month); + const days = full - first + 1; const rain = climate(src, user, "rain", month); const heat = climate(src, user, "heat", month); - const a = roundup(rain + heat, 1); - const [auto, names] = holidays(src, year, month); + const a = roundup(first === 1 ? rain + heat : ((rain + heat) * days) / full, 1); + const [auto, names] = holidays(src, year, month, first, user); const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); const b = mine !== null ? mine : auto; - const c = (a * b) / days; + let c = (a * b) / days; + if (user.c_round === "round1") c = roundHalf(c, 1); const off = rounddown(a + b - c, 0); const avail = days - off; const cap = Math.max(avail, 0); @@ -182,7 +244,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row { room -= take; } const used = use[0] + use[1] + use[2]; - const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0); + const offUsed = + cap <= 0 + ? days + : used >= cap || user.last_off === "all" + ? off + : roundup((off * used) / cap, 0); const span = used + offUsed; total += span; months.push({ @@ -209,11 +276,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row { year += 1; month = 1; } else month += 1; + first = 1; } - const first = months[0]; - const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5); + const from = new Date(begin); + const end = new Date(begin + (Math.max(total, 1) - 1) * 864e5); return { - start: { year: first.year, month: first.month }, + start: { year: from.getUTCFullYear(), month: from.getUTCMonth() + 1, day: from.getUTCDate() }, months, days: total, months_n: roundHalf(total / 30, 1), diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Source.py b/B09_Estimation/draft/B09_Draft_Schedule_Source.py index 3f9e7972c..ad206aee7 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Source.py +++ b/B09_Estimation/draft/B09_Draft_Schedule_Source.py @@ -9,12 +9,14 @@ 공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays` 목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개) 기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) — - 지역마다 사용자 고침 -사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수) + 고르기(264) = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25 「공사기간산정표」) · 0에서 직접 · + 달마다 사용자 덮개가 이김 +사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수 · 고르기 칸은 목록 안) """ from __future__ import annotations +import calendar import datetime import json from pathlib import Path @@ -22,7 +24,7 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines -from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule +from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json") CLIMATE = { @@ -30,8 +32,50 @@ CLIMATE = { "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], } +#: 기후 자료 고르기(264) — 첫 줄 = 기본 · rain_note · heat_note = 공기집계 비고 +CLIMATES = { + "geochang25": { + **CLIMATE, + "name": "거창 25 참고", + "rain_note": "일강수량 5mm 이상", + "heat_note": "33℃ 이상", + }, + "uljin24": { + "name": "울진 24 참고", + "label": "경북 울진(참고 설계서 24 · 강수 = 일강수량 5mm 이상 · 혹서 없음)", + "rain": [0.6, 1.3, 2.9, 4.3, 3.2, 3.9, 7.4, 8.3, 4, 2.8, 2.6, 1.4], + "heat": [0] * 12, + "rain_note": "일강수량 5mm 이상", + "heat_note": "혹서 없음(참고 설계서)", + }, + "sogwang25": { + "name": "울진 소광 25 참고", + "label": "경북 울진 소광(참고 설계서 25 · 강수 = 일강수량 10mm 이상 · 혹서 없음)", + "rain": [2.1, 2, 3.3, 4, 2.1, 3.7, 6.3, 7.9, 5, 4.2, 3.6, 1.6], + "heat": [0] * 12, + "rain_note": "일강수량 10mm 이상", + "heat_note": "혹서 없음(참고 설계서)", + }, + "zero": { + "name": "0에서 직접", + "label": "직접 입력(달마다 강수 · 혹서 칸)", + "rain": [0] * 12, + "heat": [0] * 12, + "rain_note": "직접 입력", + "heat_note": "직접 입력", + }, +} START_MONTH = 3 -NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy") +NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy", "rate_haul", "rate_base") +POSITIVE = ("units", "crew") +#: 고르기 칸 — 기본값이 첫째 +CHOICES = { + "climate_set": tuple(CLIMATES), + "rest": ("sun", "satsun"), + "machine_basis": ("max", "sum"), + "c_round": ("none", "round1"), + "last_off": ("ratio", "all"), +} def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: @@ -54,7 +98,13 @@ def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey machine = {"key": mkey, "name": name, "hr": hr} labor = sum((used.get("labor") or {}).values()) - out[row["key"]] = {"machine": machine, "labor": labor if labor > 0 else None} + # 264 주 장비 기준 「장비 시간 합」 = 차례로 일하는 장비 · n = 장비 수 + spent = [hr for hr in hours.values() if hr > 0] + out[row["key"]] = { + "machine": machine, + "labor": labor if labor > 0 else None, + "machine_sum": {"hr": sum(spent), "n": len(spent)} if machine else None, + } return out @@ -71,6 +121,7 @@ def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None) "units": unit_resources(sheets), "holidays": holiday_table(), "climate": CLIMATE, + "climates": CLIMATES, "start": {"year": today.year + 1, "month": START_MONTH}, } @@ -100,12 +151,25 @@ def clean_schedule(raw: Any) -> dict[str, Any]: out: dict[str, Any] = {} for field in NUMBERS: v = num(raw.get(field)) - if v is not None and 0 <= v <= 1000: + if v is not None and 0 <= v <= (100 if field.startswith("rate") else 1000): out[field] = v + for field in POSITIVE: + if (v := _positive(raw.get(field))) is not None and v <= 1e4: + out[field] = v + for field, allowed in CHOICES.items(): + if raw.get(field) in allowed: + out[field] = raw[field] + off = raw.get("holiday_off") if isinstance(raw.get("holiday_off"), list) else [] + kinds = [k for k in HOLIDAY_KINDS if k in off] + if kinds: + out["holiday_off"] = kinds start = raw.get("start") if isinstance(raw.get("start"), dict) else {} year, month = num(start.get("year")), num(start.get("month")) if year and month and 2000 <= year <= 2100 and 1 <= month <= 12: out["start"] = {"year": int(year), "month": int(month)} + day = num(start.get("day")) + if day and 1 < day <= calendar.monthrange(int(year), int(month))[1]: + out["start"]["day"] = int(day) climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {} kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))} if kept: diff --git a/resources/tester/test_264_2_b09_schedule_options.py b/resources/tester/test_264_2_b09_schedule_options.py new file mode 100644 index 000000000..2266968f5 --- /dev/null +++ b/resources/tester/test_264_2_b09_schedule_options.py @@ -0,0 +1,210 @@ +"""PLAN 264 — B09 공기 옵션(착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 · 준비 정리 · 반영률 · +주 장비 기준 · 중복 C 끝수 · 마지막 달) — 옵션마다 결과가 바뀜 · 다듬기 · 거울 TS = Py · 패널 훅.""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402 +from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402 + CLIMATES, + clean_schedule, + schedule_inputs, + unit_resources, +) +from test_252_1_b09_schedule import GEOCHANG, SRC, _bill, _ref_user, _ts_schedule # noqa: E402 + +SRC2 = {**SRC, "climates": CLIMATES} +UNITS = { + "a": { + "machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, + "labor": 0.01, + "machine_sum": {"hr": 0.0086, "n": 2}, + }, + "b": {"machine": None, "labor": 1.5}, +} +BILL = [ + {"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2}, + {"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2}, + {"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2}, +] +SRC3 = {**SRC2, "units": UNITS} +YEAR_2026 = {"start": {"year": 2026, "month": 1}, "tidy": 400} + + +def _plain(): + """거창 재현 사용자 값에서 법정공휴일 덮개만 뺌(자동 B 를 봄).""" + user = _ref_user() + del user["holiday"] + return user + + +def _cal(user, src=SRC2, bill=None): + return compute_schedule(src, _bill(GEOCHANG) if bill is None else bill, user)["calendar"] + + +def _rows(user): + return {r["key"]: r for r in compute_schedule(SRC3, BILL, user)["work"]["rows"]} + + +def test_기본값_그대로(): + base = compute_schedule(SRC, _bill(GEOCHANG), _ref_user()) + assert compute_schedule(SRC2, _bill(GEOCHANG), _ref_user()) == base + picked = {**_ref_user(), "climate_set": "geochang25", "rest": "sun", "machine_basis": "max", + "c_round": "none", "last_off": "ratio", "holiday_off": [], "units": 1, "crew": 10, + "rate_haul": 0, "rate_base": 100, "hours": 8, "prep_docs": 2, "prep_setup": 2, + "tidy": 4} # fmt: skip + assert compute_schedule(SRC2, _bill(GEOCHANG), picked) == base + assert (base["work"]["total"], base["calendar"]["days"]) == (95, 136) + + +def test_1_착공일_그날부터(): + user = {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}} + cal = _cal(user) + first = cal["months"][0] + # 3.15 ~ 3.31 = 17일 · A = ROUNDUP(3.6 × 17 ÷ 31, 1) = 2.0 · 일요일 15 · 22 · 29 + assert (first["days"], first["a"], first["b_auto"]) == (17, 2.0, 3) + assert cal["start"] == {"year": 2026, "month": 3, "day": 15} + assert cal["end"] > _cal(_plain())["end"] + assert cal["months"][1]["days"] == 30 # 둘째 달부터 달 전체 + + +def test_2_기후_자료_고르기(): + m = _cal({**_plain(), "climate_set": "uljin24"})["months"][0] + assert (m["rain"], m["heat"]) == (2.9, 0) + m = _cal({**_plain(), "climate_set": "sogwang25"})["months"][5 - 3] + assert (m["rain"], m["heat"]) == (2.1, 0) # 5월 + assert all(m["a"] == 0 for m in _cal({**_plain(), "climate_set": "zero"})["months"]) + user = {**_plain(), "climate_set": "uljin24", "climate": {"rain": [9] * 12}} + assert _cal(user)["months"][0]["rain"] == 9 # 달마다 덮개가 이김 + + +def test_3_쉬는_날_토일(): + base = _cal(_plain())["months"][0]["b_auto"] + assert _cal({**_plain(), "rest": "satsun"})["months"][0]["b_auto"] == base + 4 # 2026.3 토 4번 + + +def test_4_공휴일_빼기(): + def auto(off): + cal = _cal({**YEAR_2026, "holiday_off": off}, bill=[]) + return [m["b_auto"] for m in cal["months"][:12]] + + full = auto([]) + assert full == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5] + assert auto(["labor"])[4] == 7 # 5.1 노동절 + assert auto(["substitute"])[4] == 7 # 5.25 부처님오신날 대체 + assert auto(["election"])[5] == 5 # 6.3 지방선거 + assert auto(["constitution"])[6] == 4 # 7.17 제헌절 + may = _cal({**YEAR_2026, "holiday_off": ["labor"]}, bill=[])["months"][4]["names"] + assert not any("노동절" in n for n in may) + + +def test_5_일_작업시간(): + assert _rows({})["a"]["days"] == 0.9 + assert _rows({"hours": 4})["a"]["days"] == 1.7 # 1000 × 0.0066 ÷ 4 = 1.65 + + +def test_6_투입_기본(): + rows = _rows({"units": 2, "crew": 5}) + assert (rows["a"]["put"], rows["a"]["days"]) == (2, 0.5) # 0.4125 → 0.5 + assert (rows["b"]["put"], rows["b"]["days"]) == (5, 24) + rows = _rows({"crew": 5, "rows": {"b": {"crew": 20}}}) + assert rows["b"]["days"] == 6 # 줄마다 덮개가 이김 + + +def test_7_준비_정리_기타(): + work = compute_schedule(SRC3, BILL, {"prep_docs": 3, "prep_setup": 1, "tidy": 6, "etc": 2}) + w = work["work"] + assert (w["prep"], w["tidy"], w["etc"]) == (4, 6, 2) + assert w["total"] == 4 + round(0.9 + 12 + 0 + 2) + 6 + + +def test_8_반영률_기본(): + rows = _rows({"rate_haul": 100, "rate_base": 50}) + assert (rows["h"]["rate"], rows["a"]["rate"], rows["b"]["rate"]) == (100, 50, 50) + assert rows["b"]["days"] == 6 + assert _rows({"rate_base": 50, "rows": {"b": {"rate": 100}}})["b"]["days"] == 12 + + +def test_9_주_장비_장비_시간_합(): + a = _rows({"machine_basis": "sum"})["a"] + assert (a["per"], a["who"], a["days"]) == (0.0086, "굴착기 0.7㎥ 외 1종", 1.1) # 8.6 ÷ 8 + assert _rows({})["a"]["per"] == 0.0066 + + +def test_10_중복일수_끝수(): + base = _cal(_plain())["months"] + got = _cal({**_plain(), "c_round": "round1"})["months"] + assert all(m["c"] == round(m["c"], 1) for m in got) + assert [m["c"] for m in got] != [m["c"] for m in base] + + +def test_11_마지막_달_비작업_전부(): + base = _cal(_ref_user())["months"][-1] + last = _cal({**_ref_user(), "last_off": "all"}) + assert (base["off_used"], base["off"]) == (7, 15) + assert last["months"][-1]["off_used"] == 15 and last["days"] == 136 + 8 + + +def test_다듬기_새_칸(): + raw = { + "units": 2, "crew": 0, "rate_haul": 50, "rate_base": 120, + "climate_set": "uljin24", "rest": "sat", "machine_basis": "sum", + "c_round": "round1", "last_off": "x", "holiday_off": ["labor", "junk", "election"], + "start": {"year": 2027, "month": 2, "day": 30}, + } # fmt: skip + assert clean_schedule(raw) == { + "rate_haul": 50, + "units": 2, + "climate_set": "uljin24", + "machine_basis": "sum", + "c_round": "round1", + "holiday_off": ["election", "labor"], + "start": {"year": 2027, "month": 2}, + } + got = clean_schedule({"start": {"year": 2027, "month": 3, "day": 15}, "holiday_off": "x"}) + assert got == {"start": {"year": 2027, "month": 3, "day": 15}} + + +def test_원천_기후_셋_장비_합(): + sheets = { + "bill": {"rows": [{"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}]}, + "sg": [{"key": "S1", "lines": [ + {"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066}, + {"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002}, + ]}], + "machine": [], + } # fmt: skip + unit = unit_resources(sheets)["earth:4"] + assert unit["machine_sum"]["n"] == 2 and round(unit["machine_sum"]["hr"], 6) == 0.0086 + got = schedule_inputs(sheets) + assert list(got["climates"]) == ["geochang25", "uljin24", "sogwang25", "zero"] + assert got["climates"]["geochang25"]["rain"] == got["climate"]["rain"] + + +def test_거울_TS_옵션(): + cases = [ + (SRC2, _bill(GEOCHANG), {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}, + "climate_set": "sogwang25", "rest": "satsun", + "holiday_off": ["substitute", "constitution"], + "c_round": "round1", "last_off": "all"}), + (SRC3, BILL, {"machine_basis": "sum", "units": 3, "crew": 7, "rate_haul": 30, + "rate_base": 80, "hours": 9, "prep_docs": 1, "tidy": 2, "etc": 1, + "climate_set": "zero", "start": {"year": 2027, "month": 12, "day": 31}}), + ] # fmt: skip + for src, bill, user in cases: + py = json.loads(json.dumps(compute_schedule(src, bill, user), ensure_ascii=False)) + assert _ts_schedule(src, bill, user) == py + + +def test_패널_훅_draft_없으면_빈칸(): + tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8") + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8") + assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음 + assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell + assert "DRAFT.panels.push(" in ui From 0db53a9032437ae69ead184e039d38ed7a6d9d61 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:40:29 +0900 Subject: [PATCH 071/151] =?UTF-8?q?feat(B09):=20264-1=20=EC=99=BC=EC=AA=BD?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=E3=80=8C=EA=B3=B5=EA=B8=B0=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=E3=80=8D=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - draft 새 파일 B09_Draft_Schedule_Panel — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 · 주 장비 기준 · 중복 C 끝수 · 마지막 달 - 값 = user.schedule(캐시 → [저장]) · 표 칸과 같은 값 · 셈마다 카드 맞춤 - 훅 = DRAFT.panels(기본 빈 목록) · Shell 「데이터 점검」 아래 한 줄 - 공기집계 비고 = 고른 옵션을 따름 · 착공 칸 「연.월.일」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_UI_Shell.ts | 2 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 +- .../draft/B09_Draft_Schedule_Panel.ts | 215 ++++++++++++++++++ B09_Estimation/draft/B09_Draft_Schedule_UI.ts | 55 ++++- 4 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Panel.ts diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index d1f6797ea..474e7bcf1 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -370,7 +370,7 @@ export function renderB09Estimation(root: HTMLElement): void { actions.append(save, reset); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; - left.append(section, actions); + left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); attachCollapsible(left); const layout = createWorkflowLayout({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index dbaa1c443..97d937b6e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -122,11 +122,13 @@ export interface TabTable { export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — - * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */ + * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; -} = { builders: {}, extend: [] }; + panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; +} = { builders: {}, extend: [], panels: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts new file mode 100644 index 000000000..379a59b63 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts @@ -0,0 +1,215 @@ +/* ============================================================================= + * B09_Draft_Schedule_Panel.ts + * B09 왼쪽 패널 「공기 옵션」 카드(PLAN 264-1 · 사용자 164-2 · 초안 — draft 폴더째 지우면 사라짐). + * 등록 = `B09_Draft_Schedule_UI` 가 DRAFT.panels(카드) · DRAFT.extend(값 맞춤)에 넣음 — + * 본 코드 훅 = `…_UI_Shell` 이 「데이터 점검」 아래 · [저장] 묶음 위에 붙이는 한 줄 + * 값 = user.schedule(캐시 → [저장] 한 번에) · 공기산출 · 공기집계 표 칸과 같은 값(어디서 고쳐도 한 벌) + * 꼴 = 좌측 패널 공용(B05/B06 「구조물 배치」 칸 — 라벨 위 · 입력 아래 · 두 칸 줄 · 체크) + * ========================================================================== */ + +import "../../B05_Profile/B05_Profile_UI_Style.css"; +import { num, type Row } from "../B09_Estimation_Calc_Base"; +import { parseNumber } from "../B09_Estimation_UI_Tabs"; +import { DEFAULTS } from "./B09_Draft_Schedule_Calc"; + +type Sync = (mine: Row, src: Row) => void; + +const HOLIDAY_LABELS: Array<[string, string]> = [ + ["substitute", "대체공휴일"], + ["election", "선거일"], + ["labor", "근로자의 날(노동절)"], + ["constitution", "제헌절"], +]; +const CHOICES: Record> = { + rest: [ + ["sun", "일요일만"], + ["satsun", "토 · 일"], + ], + machine_basis: [ + ["max", "단위당 시간이 가장 긴 장비"], + ["sum", "장비 시간 합(차례로 일하는 장비)"], + ], + c_round: [ + ["none", "그대로"], + ["round1", "소수 1자리 반올림"], + ], + last_off: [ + ["ratio", "쓴 비율만큼 올림"], + ["all", "그 달 비작업 전부"], + ], +}; + +/** 지금 화면의 카드 맞춤 — 셈마다 DRAFT.extend 가 부름(카드를 다시 만들면 새 카드로) */ +let syncNow: Sync | null = null; + +export function syncSchedulePanel(payload: Row, user: Row): void { + syncNow?.(user.schedule || {}, payload.inputs?.schedule || {}); +} + +function field(label: string, input: HTMLElement): HTMLLabelElement { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = label; + wrapper.append(caption, input); + return wrapper; +} + +function pair(...items: HTMLElement[]): HTMLDivElement { + const row = document.createElement("div"); + row.className = "b05-route__field-row"; + row.append(...items); + return row; +} + +export function createSchedulePanel(user: () => Row, setUser: (next: Row) => void): HTMLElement { + const syncs: Sync[] = []; + const change = (fn: (s: Row) => void): void => { + const next: Row = structuredClone(user().schedule || {}); + fn(next); + setUser({ ...user(), schedule: next }); + }; + + /** 수 칸 — 빈칸 · 기본값 = 지움(기본) · 범위 밖 = 되돌림 */ + const number = (key: string, label: string, min: number, max: number): HTMLLabelElement => { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = "any"; + input.dataset.b09Schedule = key; + let last: Row = {}; + syncs.push((mine) => { + last = mine; + input.value = String(num(mine[key]) ?? DEFAULTS[key]); + }); + input.addEventListener("change", () => { + const v = parseNumber(input.value); + if (v === undefined || (v !== null && (v < min || v > max))) { + input.value = String(num(last[key]) ?? DEFAULTS[key]); + return; + } + change((s) => { + if (v === null || v === DEFAULTS[key]) delete s[key]; + else s[key] = v; + }); + }); + return field(label, input); + }; + + /** 고르기 칸 — 첫째 = 기본(고르면 지움) · options 없으면 원천에서(기후 자료) */ + const choose = ( + key: string, + label: string, + options?: Array<[string, string]>, + ): HTMLLabelElement => { + const select = document.createElement("select"); + select.dataset.b09Schedule = key; + let values: string[] = []; + syncs.push((mine, src) => { + const list = + options ?? + Object.entries((src.climates || {}) as Row).map(([id, c]): [string, string] => [ + id, + c.name || id, + ]); + if (list.map(([v]) => v).join() !== values.join()) { + values = list.map(([v]) => v); + select.replaceChildren(...list.map(([v, text]) => new Option(text, v))); + } + select.value = values.includes(mine[key]) ? mine[key] : (values[0] ?? ""); + }); + select.addEventListener("change", () => + change((s) => { + if (select.value === values[0]) delete s[key]; + else s[key] = select.value; + }), + ); + return field(label, select); + }; + + const start = document.createElement("input"); + start.type = "date"; + start.dataset.b09Schedule = "start"; + syncs.push((mine, src) => { + const at: Row = { ...(src.start || {}), ...(mine.start || {}) }; + const pad = (n: unknown): string => String(Number(n) || 1).padStart(2, "0"); + start.value = at.year ? `${at.year}-${pad(at.month)}-${pad(at.day)}` : ""; + }); + start.addEventListener("change", () => { + const hit = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start.value); + change((s) => { + if (!hit) return void delete s.start; + const day = Number(hit[3]); + s.start = { year: Number(hit[1]), month: Number(hit[2]), ...(day > 1 ? { day } : {}) }; + }); + }); + + const checks = HOLIDAY_LABELS.map(([kind, label]) => { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__check"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.b09Holiday = kind; + syncs.push((mine) => { + input.checked = !(mine.holiday_off || []).includes(kind); + }); + input.addEventListener("change", () => + change((s) => { + const off = HOLIDAY_LABELS.map(([k]) => k).filter((k) => + k === kind ? !input.checked : (s.holiday_off || []).includes(k), + ); + if (off.length) s.holiday_off = off; + else delete s.holiday_off; + }), + ); + wrapper.append(input, document.createTextNode(label)); + return wrapper; + }); + const holidayBox = document.createElement("div"); + holidayBox.className = "b05-route__field"; + const holidayCaption = document.createElement("span"); + holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)"; + holidayBox.append(holidayCaption, ...checks); + + const body = document.createElement("div"); + body.className = "b05-route__panel-body"; + body.append( + field("착공일", start), + choose("climate_set", "기후 자료(달마다 덮개는 공기집계 표)"), + choose("rest", "쉬는 날", CHOICES.rest), + holidayBox, + number("hours", "일 작업시간(시간)", 0.5, 24), + pair( + number("units", "장비 기본 대수", 0.001, 1e4), + number("crew", "인력 기본 인원", 0.001, 1e4), + ), + pair( + number("prep_docs", "서류 · 착수 준비(일)", 0, 1000), + number("prep_setup", "공종별 준비(일)", 0, 1000), + ), + pair(number("tidy", "정리(일)", 0, 1000), number("etc", "기타 · 현장여건(일)", 0, 1000)), + pair( + number("rate_haul", "반영률 운반공(%)", 0, 100), + number("rate_base", "반영률 그 밖(%)", 0, 100), + ), + choose("machine_basis", "주 장비 기준", CHOICES.machine_basis), + choose("c_round", "중복일수 C 끝수", CHOICES.c_round), + choose("last_off", "마지막 달 비작업일", CHOICES.last_off), + ); + const section = document.createElement("section"); + section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + section.dataset.b09SchedulePanel = ""; + section.hidden = true; // 원천(서버 공기 자료)이 오면 보임 + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = "공기 옵션"; + section.append(heading, body); + + syncNow = (mine, src) => { + section.hidden = !src.climates; + for (const sync of syncs) sync(mine, src); + }; + syncNow(user().schedule || {}, {}); + return section; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts index ad39247d6..45f700bdf 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -4,6 +4,7 @@ * 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS · * DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐 * 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule` + * 왼쪽 「공기 옵션」 카드 = `B09_Draft_Schedule_Panel`(264-1 · 같은 값 · 비고는 고른 옵션을 따름) * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) * ========================================================================== */ @@ -21,7 +22,8 @@ import { type TabId, type TabTable, } from "../B09_Estimation_UI_Tabs"; -import { computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { climateBase, computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { createSchedulePanel, syncSchedulePanel } from "./B09_Draft_Schedule_Panel"; const SUM = "schedule_sum" as TabId; const CALC = "schedule_calc" as TabId; @@ -185,6 +187,35 @@ const LINES: Array<[string, string, string, string]> = [ ["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"], ]; +/** 264 고른 옵션을 따르는 비고 — 기본과 같으면 LINES 글 그대로(rain 은 늘 기후 자료 이름) */ +function optionNotes(src: Row, mine: Row, midMonth: boolean): Record { + const base = climateBase(src, mine); + const out: Record = {}; + if (base.label) + out.rain = `${base.rain_note ?? "일강수량 5mm 이상"}(달마다 · 해 무관) · 기본값 = ${base.label}`; + if (base.heat_note) out.heat = base.heat_note; + if (midMonth) { + out.days = "첫 달 = 착공일부터"; + out.a = "ROUNDUP(강수 + 혹서, 1) · 첫 달 = 날수 비례"; + } + const off: string[] = mine.holiday_off || []; + if (mine.rest === "satsun" || off.length) { + const rest = mine.rest === "satsun" ? "토 · 일" : "일요일"; + const cut = HOLIDAY_NAMES.filter(([k]) => off.includes(k)).map(([, n]) => n); + out.b = `${rest} + 공휴일${cut.length ? `(뺌 = ${cut.join(" · ")})` : ""} · 풍선 = 이름 · 고치면 그 달 덮개`; + } + if (mine.c_round === "round1") out.c = "A × B ÷ 달력일수 · 소수 1자리 반올림"; + if (mine.last_off === "all") out.off_used = "다 쓴 달 · 마지막 달 = 그 달 비작업 전부"; + return out; +} + +const HOLIDAY_NAMES: Array<[string, string]> = [ + ["substitute", "대체"], + ["election", "선거일"], + ["labor", "노동절"], + ["constitution", "제헌절"], +]; + export function buildScheduleSum(ctx: TabContext): TabTable | string { const sch: Row | undefined = ctx.sheets.schedule; if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; @@ -192,7 +223,9 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { const months: Row[] = cal.months; const src: Row = ctx.payload.inputs?.schedule || {}; const mine: Row = ctx.user.schedule || {}; - const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`; + const day = cal.start.day > 1 ? `.${String(cal.start.day).padStart(2, "0")}` : ""; + const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}${day}`; + const notes = optionNotes(src, mine, Boolean(day)); const columns: DataColumn[] = [ text("label", "구분", 96), text("sub", "세부", 96), @@ -212,15 +245,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, }, ...LINES.map(([key, label, sub, note]) => { - const row: DataRow = { key, label, sub, note }; + const row: DataRow = { key, label, sub, note: notes[key] ?? note }; let sum = 0; months.forEach((m, i) => { row[`m${i}`] = m[key]; sum += Number(m[key]) || 0; }); row.sum = sum; - if (key === "rain" && src.climate?.label) - row.note = `${note} · 기본값 = ${src.climate.label}`; return row; }), ]; @@ -232,7 +263,7 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { frozen: 2, mergeSame: ["label"], rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), - hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", tip: (i, key) => { const m = months[monthOf(key)]; if (!m || rows[i].key !== "b") return null; @@ -266,10 +297,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { const k = String(rows[i].key); change(ctx, (s) => { if (k === "start") { - const hit = /(\d{4})\D+(\d{1,2})/.exec(value); + const hit = /(\d{4})\D+(\d{1,2})(?:\D+(\d{1,2}))?/.exec(value); if (!value.trim()) delete s.start; - else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) - s.start = { year: Number(hit[1]), month: Number(hit[2]) }; + else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) { + const [year, month, day] = [Number(hit[1]), Number(hit[2]), Number(hit[3] ?? 1)]; + const last = new Date(Date.UTC(year, month, 0)).getUTCDate(); + s.start = { year, month, ...(day > 1 && day <= last ? { day } : {}) }; + } return; } const m = months[monthOf(key)]; @@ -303,4 +337,7 @@ if (!TABS.some(([id]) => id === SUM)) { const src = payload.inputs?.schedule; if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {}); }); + // 264-1 왼쪽 「공기 옵션」 카드 — 셈마다 지금 값으로 맞춤 + DRAFT.panels.push(createSchedulePanel); + DRAFT.extend.push(syncSchedulePanel); } From 3939cd11f967196c0b1bdcd3b3158d0eab579dac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:42:57 +0900 Subject: [PATCH 072/151] =?UTF-8?q?feat(B07):=20269-1=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EC=96=91=EC=8B=9D(=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=EA=B8=B0=EC=A4=80)=20=C2=B7?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=EC=B9=B4=EB=93=9C=20=ED=95=A8=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template: .ui-panel-form(칸 26px · 라벨 틈 2px · 캡션 글자 · 단추 크기) · .ui-panel-btn · createPanelCard - 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰 - B07 평면도 · 횡단도 묶음 = 공용 카드 · 도면 단추 = .ui-panel-btn · 페이지 덧칠 걷음 - 시험 test_269_panel_form · 212-3 기대값 · 130 · 153 헬퍼 공용 카드 연결 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- .../B05_Profile_UI_Style_Structures.css | 6 +- .../B07_DesignDetail_UI_Panels.ts | 16 ++-- .../B07_DesignDetail_UI_PlanKinds.ts | 8 +- .../B07_DesignDetail_UI_Style.css | 46 +-------- .../tester/helper_130_b07_plan_manage.cjs | 4 +- .../tester/helper_153_b07_cross_layout.cjs | 4 +- .../test_212_3_b07_cross_layout_buttons.py | 17 ++-- resources/tester/test_269_panel_form.py | 96 +++++++++++++++++++ ui_template/ui_template_collapsible.ts | 11 +++ ui_template/ui_template_overlay.css | 71 ++++++++++++++ ui_template/ui_template_theme.css | 3 + 11 files changed, 210 insertions(+), 72 deletions(-) create mode 100644 resources/tester/test_269_panel_form.py diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index c3f163646..8345e861e 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -357,11 +357,11 @@ /* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ────────────────────── * 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다. - * 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의 + * 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의 * 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */ .b05-structure-section, .b05-drainage__facility { - --b05-control-h: 26px; + --b05-control-h: var(--panel-control-h); } .b05-structure-section input:not([type="checkbox"]), @@ -409,7 +409,7 @@ /* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은 조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */ .b05-structure-section { - --b05-label-gap: 2px; + --b05-label-gap: var(--panel-label-gap); } .b05-structure-section .b05-route__field { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 68426a2fa..d19228411 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -6,7 +6,7 @@ * DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다. * ========================================================================== */ -import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; @@ -79,7 +79,7 @@ export function buildDrawingSidePanel( const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; - button.className = "b07-drawing-button"; + button.className = "b07-drawing-button ui-panel-btn"; button.dataset.drawingId = drawing.id; button.dataset.confirmed = String(drawing.confirmed); const name = document.createElement("span"); @@ -114,7 +114,7 @@ export function buildDrawingSidePanel( if (!drawing) { const placeholder = document.createElement("button"); placeholder.type = "button"; - placeholder.className = "b07-drawing-button"; + placeholder.className = "b07-drawing-button ui-panel-btn"; placeholder.disabled = true; placeholder.title = "준비 중"; placeholder.dataset.pending = "true"; @@ -132,14 +132,10 @@ export function buildDrawingSidePanel( panel.append(button); continue; } - const section = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). - section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; + // 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식). + const section = createPanelCard(`${group.label} ${items.length}`); + section.classList.add("b07-drawing-group"); if (group.kind) section.dataset.kind = group.kind; - const sectionTitle = document.createElement("h3"); - sectionTitle.className = "ui-collapsible__title"; - sectionTitle.textContent = `${group.label} ${items.length}`; - section.append(sectionTitle); if (cross) { // 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만. for (const standard of drawings.filter((item) => item.kind === "cross_standard")) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 1b3145360..3d04ef741 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createPanelCard } from "@ui/ui_template_collapsible"; import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { @@ -257,9 +258,10 @@ export function buildPlanSection( makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, onManage?: () => void, ): HTMLElement { - const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); + // 컨테이너 = 좌측 패널 공용 카드(269) + const section = createPanelCard(`평면도 ${items.length}`); + section.classList.add("b07-drawing-group", "b07-plan"); section.dataset.kind = "plan"; - section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); for (const { kindId, name, sheets } of groupPlanSheets(items)) { const block = el("div", "b07-plan-kind"); @@ -273,7 +275,7 @@ export function buildPlanSection( if (onManage) { // 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1). - const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리"); + const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리"); manage.type = "button"; manage.addEventListener("click", onManage); section.append(manage); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 71c501fb3..9e7c36ded 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -40,15 +40,6 @@ font-size: var(--text-body-sm); } -.b07-drawing-group { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - /* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */ - border-radius: var(--radius-lg); - padding: var(--spacing-8); -} - /* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08). 좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용 오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px @@ -62,13 +53,6 @@ gap: var(--spacing-4); } -.b07-drawing-group h3 { - margin: var(--spacing-8) 0 var(--spacing-4); - color: var(--color-text-muted); - font-size: var(--text-caption); - font-weight: 600; -} - /* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되 못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */ .b07-drawing-button[data-pending="true"] { @@ -94,22 +78,13 @@ /* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가 오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */ min-width: 0; - min-height: 34px; - padding: var(--spacing-4) var(--spacing-8); - /* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */ - border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent); - /* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */ + /* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */ border-left: 3px solid transparent; - border-radius: var(--radius-buttons); - background: color-mix(in srgb, var(--color-surface) 84%, #000); - color: var(--color-text); - cursor: pointer; text-align: center; } .b07-drawing-button__name { overflow: hidden; - font-size: var(--text-body-sm); white-space: nowrap; text-overflow: ellipsis; } @@ -227,7 +202,7 @@ /* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일: 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기]. - 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */ + 단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */ .b07-cross-layout-slot { grid-column: 1 / -1; } @@ -260,17 +235,8 @@ gap: var(--spacing-4); } -.b07-cross-layout .ui-btn, -.b07-cross-layout .ui-field .ui-input { - height: auto; - padding: var(--btn-bar-padding); - font-size: var(--btn-bar-font-size); - line-height: 1; -} - .b07-cross-layout .ui-field__label { margin: 0; - font-size: var(--btn-bar-font-size); white-space: nowrap; } @@ -279,10 +245,6 @@ min-width: 0; } -.b07-cross-layout .ui-field__error { - display: none; -} - /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ .b07-drawing-list__bypass { margin: var(--spacing-4) 0 0; @@ -315,10 +277,6 @@ font-size: var(--text-caption); } -.b07-plan-manage-open { - margin-top: var(--spacing-4); -} - /* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */ .ui-modal.b07-plan-manage { width: min(1320px, 96vw); diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 612ce5081..b3a1e2334 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -140,7 +140,6 @@ const STUBS = { return cad; }, }, - "@ui/ui_template_collapsible": { attachCollapsible: () => {} }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), @@ -233,6 +232,9 @@ function loadTs(file) { cache.set(full, box.exports); const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + // 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎) + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); if (request === "./B07_DesignDetail_Api_Fetch") return API; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); throw new Error(`stub 없음: ${request}`); diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index c80f3c94c..147bce9d6 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -78,7 +78,9 @@ function loadTs(file) { ...loadTs(path.join(UI, "ui_template_elements.ts")), showToast: (message, kind) => toasts.push([kind, message]), }; - if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} }; + // 공용 카드(269) = 진짜 모듈 + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(UI, "ui_template_collapsible.ts")); if (request === "@ui/ui_template_locale") return { currentLanguageIndex: 0, diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py index 3b51c0f18..6cc3f02fd 100644 --- a/resources/tester/test_212_3_b07_cross_layout_buttons.py +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -4,7 +4,7 @@ 못박는 것 - 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」 · [안내선 숨기기](부르는 쪽). - - 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비. + - 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비. - 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음. - [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5` · 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔. @@ -40,16 +40,13 @@ def _decl(block: str, prop: str) -> str: def test_단추_숫자_칸_한_규격_토큰(): - shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input") - assert _decl(shared, "padding") == "var(--btn-bar-padding)" - assert _decl(shared, "font-size") == "var(--btn-bar-font-size)" - assert _decl(shared, "line-height") == "1" - assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤 + # 269 — 크기는 좌측 패널 공용 양식(`.ui-panel-form`)이 줌 · B07 덧칠(줄 위 작은 단추 토큰) 없음 + assert ".b07-cross-layout .ui-btn" not in CSS + assert ".b07-cross-layout .ui-field .ui-input {" in CSS # 폭만 + assert "height" not in _block(".b07-cross-layout .ui-field .ui-input") label = _block(".b07-cross-layout .ui-field__label") - assert _decl(label, "font-size") == "var(--btn-bar-font-size)" - # 토큰은 theme 한 곳(187) - assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME - assert "--btn-bar-font-size: var(--text-caption);" in THEME + assert "font-size" not in label + assert "createPanelCard(" in PANELS # 배치 칸이 사는 횡단도 묶음 = 공용 카드 def test_한_줄에_한_일_같은_너비(): diff --git a/resources/tester/test_269_panel_form.py b/resources/tester/test_269_panel_form.py new file mode 100644 index 000000000..1bd68f6ec --- /dev/null +++ b/resources/tester/test_269_panel_form.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""269 좌측 패널 컨테이너 공용 양식 — 기준 = B05/B06 「구조물 배치」. + +못박는 것 + - 공용 양식 = ui_template 한 곳(`.ui-panel-form` · `.ui-panel-btn` · 카드 `createPanelCard`) · + 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰. + - B07 컨테이너 = 공용 카드 · 도면 단추 = `.ui-panel-btn` · 페이지 덧칠(크기 · 글자 · 바탕) 없음. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +THEME = _read("ui_template/ui_template_theme.css") +OVERLAY = _read("ui_template/ui_template_overlay.css") +COLLAPSIBLE = _read("ui_template/ui_template_collapsible.ts") +B05_STRUCT_CSS = _read("B05_Profile/B05_Profile_UI_Style_Structures.css") +B07_CSS = _read("B07_DesignDetail/B07_DesignDetail_UI_Style.css") +B07_PANELS = _read("B07_DesignDetail/B07_DesignDetail_UI_Panels.ts") +B07_PLAN = _read("B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts") + + +def _block(css: str, selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_공용_양식_토큰은_theme_한_곳_구조물_배치도_같은_토큰(): + assert "--panel-control-h: 26px;" in THEME + assert "--panel-label-gap: 2px;" in THEME + assert "--b05-control-h: var(--panel-control-h);" in B05_STRUCT_CSS + assert "--b05-label-gap: var(--panel-label-gap);" in B05_STRUCT_CSS + + +def test_공용_양식_칸_라벨_단추_규칙(): + field = _block( + OVERLAY, + '.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),\n' + ".ui-panel-form select", + ) + assert "height: var(--panel-control-h);" in field + assert "padding: 0 6px;" in field + assert "font-size: var(--text-caption);" in field + label = _block(OVERLAY, ".ui-panel-form .ui-field,\n.ui-panel-form .b05-route__field") + assert "gap: var(--panel-label-gap);" in label + assert "display: none;" in _block(OVERLAY, ".ui-panel-form .ui-field__error:not(.is-visible)") + button = _block(OVERLAY, ".ui-panel-form .ui-btn:not(.ui-help-btn)") + # 「구조물 배치」 단추(b05-route__irregular-btn)와 같은 크기 + assert "padding: var(--spacing-8);" in button + assert "font-size: var(--text-caption);" in button + assert "border-radius: var(--radius-inputs);" in button + raw = _block(OVERLAY, ":where(.ui-panel-btn)") + for decl in ( + "padding: var(--spacing-8);", + "border-radius: var(--radius-inputs);", + "background: var(--color-surface);", + "font-size: var(--text-caption);", + ): + assert decl in raw, decl + # 아래 단추 묶음은 컨테이너 밖 — 공용 양식이 `.ui-sidebar-actions` 를 겨누지 않음 + form_rules = OVERLAY[OVERLAY.index("좌측 패널 컨테이너 공용 양식(269)") :] + form_rules = form_rules[: form_rules.index("/* 카드 안에 다시 카드")] + assert "ui-sidebar-actions {" not in form_rules and "ui-sidebar-actions ." not in form_rules + + +def test_공용_카드_함수(): + assert "export function createPanelCard(" in COLLAPSIBLE + assert "ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form" in COLLAPSIBLE + + +def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴(): + assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS + assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN + for src in (B07_PANELS, B07_PLAN): + assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음 + assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2 + assert '"ui-panel-btn b07-plan-manage-open"' in B07_PLAN + + +def test_B07_페이지_덧칠_없음(): + assert not re.search(r"(?:^|\n)\.b07-drawing-group\s*\{", B07_CSS) + assert ".b07-drawing-group h3 {" not in B07_CSS + assert ".b07-plan-manage-open {" not in B07_CSS + button = _block(B07_CSS, ".b07-drawing-button") + for prop in ("min-height", "padding", "border-radius", "background", "font-size"): + assert not re.search(r"(?:^|\s)" + prop + r"\s*:", button), prop + assert "font-size" not in _block(B07_CSS, ".b07-drawing-button__name") + assert ".b07-cross-layout .ui-btn" not in B07_CSS + assert ".b07-cross-layout .ui-field__error" not in B07_CSS diff --git a/ui_template/ui_template_collapsible.ts b/ui_template/ui_template_collapsible.ts index c179e6838..9a0e77b4d 100644 --- a/ui_template/ui_template_collapsible.ts +++ b/ui_template/ui_template_collapsible.ts @@ -11,6 +11,17 @@ * 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다. * ========================================================================== */ +/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임. */ +export function createPanelCard(title: string, collapsed = false): HTMLElement { + const section = document.createElement("section"); + section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = title; + section.append(heading); + return section; +} + /** root 아래 `.ui-collapsible__title` 클릭을 위임 처리해 해당 컨테이너를 접거나 편다. */ export function attachCollapsible(root: HTMLElement): void { root.addEventListener("click", (event) => { diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index c9b6c76c5..09a1be164 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -415,6 +415,77 @@ font-size: var(--text-body-sm); } +/* 좌측 패널 컨테이너 공용 양식(269) — 기준 = B05/B06 「구조물 배치」(칸 높이 · 라벨 · 글자 · 단추). + B05~B09 좌측 컨테이너가 `.ui-panel-form`(카드는 `createPanelCard`)을 붙임 · 아래 단추 묶음 + (`.ui-sidebar-actions`)은 컨테이너 밖이라 안 먹음 · 날 단추는 `.ui-panel-btn`. */ +:where(.ui-sidebar-card.ui-panel-form) { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.ui-panel-form .ui-field, +.ui-panel-form .b05-route__field { + gap: var(--panel-label-gap); +} + +.ui-panel-form .ui-field__label, +.ui-panel-form .b05-route__field > span { + font-size: var(--text-caption); + line-height: 1.2; +} + +.ui-panel-form .ui-field__error:not(.is-visible) { + display: none; +} + +.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]), +.ui-panel-form select { + box-sizing: border-box; + height: var(--panel-control-h); + padding: 0 6px; + font-size: var(--text-caption); + line-height: normal; +} + +.ui-panel-form select.ui-select { + padding-right: 24px; + background-position: right 6px center; + background-size: 12px; +} + +/* 숫자 칸 증감 화살표 없음 — 「구조물 배치」와 같게 */ +.ui-panel-form input[type="number"] { + appearance: textfield; + -moz-appearance: textfield; +} + +.ui-panel-form input[type="number"]::-webkit-outer-spin-button, +.ui-panel-form input[type="number"]::-webkit-inner-spin-button { + margin: 0; + appearance: none; + -webkit-appearance: none; +} + +/* 단추 크기 = 「구조물 배치」 단추(`b05-route__irregular-btn`) · 색은 단추 갈래(filled · ghost) 그대로 */ +.ui-panel-form .ui-btn:not(.ui-help-btn) { + padding: var(--spacing-8); + border-radius: var(--radius-inputs); + font-size: var(--text-caption); + line-height: normal; +} + +/* 날