From 056ddd882261fb2edfa82a795afd503edee04120 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:19:18 +0900 Subject: [PATCH 01/36] auto: 2026-10-04 17:19 (ESD_LAPTOP) --- .../raw/M02_구조물조합개편/사용자요구_1004.md | 50 ++++++++ docs/raw/M02_구조물조합개편/설계_정함.md | 105 +++++++++++++++++ ...가산출_흐름도_조합다이어그램_마스터요소.md | 108 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004.md create mode 100644 docs/raw/M02_구조물조합개편/설계_정함.md create mode 100644 docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004.md b/docs/raw/M02_구조물조합개편/사용자요구_1004.md new file mode 100644 index 000000000..936caa071 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004.md @@ -0,0 +1,50 @@ +# M02 구조물 조합 개편 — 사용자 요구 (2026-10-04 · 원문 그대로) + +1번(계획서 32장 이후 정리)은 브레인이 처리함(c8269a969). 7 · 8번은 원문에 없음. + +## 2. 일위대가 조합 → 구조물 조합 하위 + +M02페이지의 마스터 템플릿 페이지에서 일위대가 조합은 구조물 조합의 하위 컨테이너로 넣을 것.(구조물 도면과 면적산출 사이에 배치) +- 일위대가 조합의 좌측 패널의 기존 보기 방식인 트리구조는 필요없으며 구조물 도면 처럼 리스트형태로 보임 +- 추가로 구조물 도면, 일위대가, 면적산출의 최상위에는 전체항목을 넣어주고 우측 상세에는 리스트로 보여줌, M01 항목을 보면 양식화되어 있는 페이지 헤이아웃 있음 + +## 3. 이름 + +이동한 일위대가 조합은 일위대가로 명칭 변경 + +## 4. 구분선 + +구조물 조합 컨테이너 내부의 서브 컨테이너 항목과 트리구조 사이에 수평 구분선 추가 + +## 5. 사용자별 구조물 조합 읽기 / 쓰기 / 복사 권한 점검 및 개선 + +- 시스템 관리자 : 아이슬로 프로그램 관리자로 마스터를 제공하여 회사관리자나 일반 사용자가 사용하기 쉽게 한다. +- 시스템관리자가 대시보드의 컨테이너 제목의 우측의 회사명을 선택함에 따라 변경한 목적은 해당 회사의 정보를 접근하고나 AS을 목적으로 고객을 도와주기 위함임. 복사항목에는 마스터나 시스템 관리자 회사, 개인으로 들어갈 항목 뿐만 아니라 고객 회사의 특정 폴더로 복사할 수 있는 기능이 있어야함. 수정/생성권한 포함 +- 회사관리자 : 고객으로 본인회사의 양식을 만들거나 개인 프로젝트에 사용할 개인 마스터데이터 관리 +- 회사관리자는 개인버튼을 누를경우 최상위 트리에는 다른 개인들의 정보를 보고 복사하여 개인에게 복사하거나 수정, 생성도 가능해야함. +- 개인은 마스터와 회사는 읽기만, 다른 개인의 정보는 볼수 없음. 트리는 현재를 유지해야함. + +## 6. 구조물 조합 항목 변경 + +- 최상위 구조물 조합에 구조물 도면, 일위대가, 면적산출로만 표현(기존의 재료산출의 사용자가 추가하는 재료들과 값은 엑셀을 사용하지 않고 일위대가의 담은로직 처럼 하위에 별도의 컨테이너로 추가되는 자재항목을 넣어야함. 이때 M01자재목록에서 하나씩 가져오되 수량은 면적산출의 알약으로 값을 싱크하거나 별도의 모달창에서 셀을 지정하게 구현) +- 재료산출 항목은 삭제 +- 선택입력 항목은 삭제(구조물 조합에서는 각 옵션의 상세 구조물에 따라 미리 옵션을 다 지정할 생각임으로 그럴필요 없음) +- 면적산출의 미리보기에서 아래 알약 그룹 행을 하이드/쇼 되게 반영(기본값은 하이드), 단, 면적산출 상세에서는 알약 행 보이기가 기본값으로 +- 면적산출 상세 화면의 우측 패널에서 현재의 일위대가 조합 목록을 검색하는 기능이 아니라 현재 구조물도의 일위대가 담은 로직과 추가될 사용자 지정 재료를 보여줄것, +- 일위대가 상세화면에서는 미리보기(보기만/저장안함) 컨테이너는 각 단가산출 로직에 입력이 필요한 정보들임. 구조물별로 하나씩만들어져야함으로 구조물의 일위대가 항목에 옵션값이 저장되야함. 그리고 이값에 대한 지정은 담은 로직 컨테이너의 항목을 누르면 모달창이 나오고 사용자가 입력 및 변경할수 있게함. (하위에 지정된 항목과 값을 작은 글씨로 표현되게 반영) +- 담은 로직에는 기준 단가를 기준으로 넣었을때 나온 값과 수량산출값을 넣었을때 값에 대한 노무비, 재료비, 경비, 합계를 같이 보여줌. 이때 수량산출은 면적산출의 특정 셀값이나 사용자가 수기로 입력한 값이 될수 있음. 이렇게 되면 미리보기 계산 컨테이너 필요 없음. +- 일위대가의 상세 화면의 순서는 담은로직 > 자재목록 > 다이어그램 순서임. +- 면적산출 산출 상세의 우측 패널에 보여져야하는 결과는 결국 일위대가의 담은 로직컨테이너와 자재목록임. +- 추가된 자재목록에도 기준에 대한 단가와, 면적산출 엑셀의 특정 셀과 연결되었을때 나온 단가가 같이 나와야함. + +## 9. 오른쪽 클릭 + +프로그램 B와 M영역의 페이지들에 마우스 오른쪽 클릭을 할 경우 브라우저 옵션 안보이게 할것.(우리 프로그램의 옵션은 허용, 공통양식 사용) + +## 사용자 답 (10-4 · 일위대가 · 원문 그대로) + +일위대가의 경우 +1. 구조물 조합을 복사한 경우 신규 키를 생성하여 만듬. (값과 로직은 일단 같이 복사) +2. 신규 구조물 조합을 생성하는 경우 미리보기 페이지에는 새로만들기와 복사생성하기를 선택하게 하고 복사 생성인 경우에는 해당 리스트를 보여주고 선택하게 보여주는 값은 개인인 경우에는 개인을, 회사관리자인경우 개인, 시스템관리자인 경우 마스터를 기본값으로 보여주고 탭을 눌러 다른 항목에서 가져올수 있는 항목들을 보여준다. +3. 다시 말해 최종적으로는 구조물 조합에 일위대가, 도면, 면적산출이 연되는게 맞으나 특정항목만 다른 값을 복사하여 가져올수 있어야함. 저장이 된 데이터라도 변경가능해야함. +4. 현재 매칭이 안된 데이터는 나중에 삭제할예정이나 마스터 데이터를 수동 생성을 위해서 필요한 데이터들임. 차라리 실무문서라는 항목을 마스터에 별도로 만들어서 나중에 한번에 지우게 할수 있게 반영. diff --git a/docs/raw/M02_구조물조합개편/설계_정함.md b/docs/raw/M02_구조물조합개편/설계_정함.md new file mode 100644 index 000000000..229b350fd --- /dev/null +++ b/docs/raw/M02_구조물조합개편/설계_정함.md @@ -0,0 +1,105 @@ +# M02 구조물 조합 개편 — 정함 (브레인 · 10-4) + +근거 = `사용자요구_1004.md` · 조사 보고 A~H(각 창 `tmp/조사_39-0X.md`). 사용자가 바꿀 수 있음. + +## 1. 자료 틀 (B · sub7) + +- 일위대가 = 구조물 열마다 `labor/<열id>.json` · 번호 `SU-######`(도번 · 면적번과 같은 뒷자리 · 층마다 셈) · 자리 = 시스템 `resources/master_template/labor/` · 회사 · 개인 `…/templates/labor/` · 새 프로젝트 씨앗도 KINDS 로 함께. +- 문서 꼴: + +```json +{ + "종류": "일위대가", "판": 1, "열": "item1", "일위대가번": "SU-000001", "단위": "개소", + "본뜬": { "층": "system", "주인": null, "키": "UA000008" }, + "담은로직": [ + { "로직": "GF000219", "메모": "", "입력값": { "형식": "메쌓기" }, "수량": null }, + { "로직": "GF000137", "메모": "", "입력값": {}, "수량": { "손": 3 } } + ], + "자재": [ { "코드": "MT000008", "이름": "치즐", "규격": "", "단위": "EA", "수량": null } ], + "계산값": { "줄": [], "자재": [], "기준합": {}, "반영합": {} } +} +``` + +- 이름 = 구조물 열 이름(따로 안 둠) · 단위 = 자기 칸 · 같은 로직 두 번 금지 · 빈 일위대가 저장 됨 · 이름 겹침 검사 없음 · 입력값 = 그 로직 입력 이름만(모르는 이름 버림 · 빈 값 = 자동값). +- 수량 = `null`(면적산출 알약 연결 따름 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」 · 칸 연결 정본 = 면적산출 문서 `연결` 한 곳) | `{손: 수}`(손 입력 · 손이 칸보다 앞섬). +- 금액 = 기준(결과단위 그대로 · 원/10㎡ 면 10㎡ 값 · 단위 같이 표기) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 「원/1,000본」 배 1000(`_PER` 고침) · 자재 기준 단가 = M01 `master_material.element` 기본값. +- 계산 자리 = 기준 ③ 서버(M01 엔진 · calc 길 · 화면은 최신 응답만) · 반영 곱 ① 짝(`M02_MasterTemplete_Labor_Calc.ts` ↔ `M02_Labor_Doc.py` · 두 파일 머리에 짝 · 거울 시험 · 면적 칸 값이 바뀌면 화면이 바로 곱함) · [저장] 때 서버가 `계산값` 을 다시 적음(브라우저 값 버림 · 목록 합계용) · 상세는 늘 새로 셈. +- 길 = 문서 `GET/PUT /api/m02/layers/{층}/templates/labor/{열}`(양식 길 · owner) · 계산 `POST /api/m02/layers/{층}/labor/{열}/calc` {doc?, 수량?: {키: 값}} → {줄[{로직, 분모, 배, 수량, 수량출처, 기준{노무비,재료비,경비,계}, 반영{…}, 까닭}], 자재[{코드, 기준단가, 수량, 수량출처, 반영, 까닭}], 기준합, 반영합} · 알약 `GET /api/m02/layers/{층}/labor/{열}/pills`(로직 물량 + 자재). +- 집계표 열 `일위대가` · `자재조합` 칸 지움(정본 = labor 파일) · SS 장부 줄 = `일위대가번` · `도번` · `면적번`. +- 옮기기(한 번 · `M02_Labor_Migrate.py`) = 층마다 열마다 labor 파일 · 열 `일위대가` 키 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · 수량 null · 자재 []) · 여러 열이 쓰던 조합(UP10-000001)은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 몇 번 돌려도 같음 · 시스템 층(git)은 바로 · storage 층은 압축 사본 뒤 브레인이 돌림. +- 실무문서 = 마스터 `resources/master_data/일위대가조합.json` 통째(37 줄 · 손대지 않음 · 옮긴 뒤 어느 열도 안 가리킴 · M01 일위대가 조합 화면도 그대로) · 회사 · 개인 옛 조합(UC · UP)은 파일 그대로 · 화면에서 뺌. +- 프로젝트 = 스냅샷 `일위대가.json` 은 labor 문서 + 담은 로직 줄 · `입력값.json`(모양 그대로) 첫 값 = labor 입력값 · `단가.json` 에 자재 MT 줄 · 계산은 반영 같은 함수. +- 은퇴(화면이 안 부른 뒤) = 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 · 미리 보기 길(preview) · 일위대가 조합 나무 길. +- 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서(32장 「로직 지우기 서버 검사」 줄 끝남). + +## 2. 좌측 패널 (A · sub5) + +- 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · 구조물 조합 안 차례 = 층 단추 + 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출. +- 하위마다 첫 줄 「전체 항목」(누르면 `onAll(kind)` → H 목록) · 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = `onOpen({kind: "labor", name: 열})`(C 상세). +- 이름 「일위대가」(하위 제목 · 상세 제목 · 대시보드 카드 글) · 해시 `open=일위대가` = 구조물 조합 펼침 + 일위대가 하위 펼침 + 전체 항목. +- 「실무문서」 = 구조물 조합 밖 별도 컨테이너(도면 양식 아래) · 마스터 층 · 시스템 관리자만 · 목록 + 상세 읽기만(새 파일 `M02_MasterTemplete_Practice.ts`) · [통째 지우기](물음 두 번 · 서버 = 시스템 관리자 · 쓰는 곳 0 확인 · 줄을 비움 · 버튼은 사용자가 누를 때만). + +## 3. 일위대가 상세 (C · sub2) + +- UI_Main 종류 `labor` 로 엶(양식 길 읽기 · [저장] · [다시 읽기] · 캐시 = 다른 양식과 같음) · 차례 = 머리 → 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램 · 미리 보기 칸 지움. +- 담은 로직 줄 누름 = 입력 모달(그 로직 입력 전부 · 빈 칸 자동값 · 수량 = 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」 = 면적산출 상세로) · [확인] = 줄 값만(캐시) · 저장은 [저장]. +- 줄 아래 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 빈 입력 「빈 값 n」 붉게. +- 금액 칸 넷(노무비 · 재료비 · 경비 · 합계) · 칸마다 두 줄(위 기준 작게 · 아래 반영) · 맨 아래 합계 줄. +- 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액 · [자재 추가](M01 자재 하나씩 · `M02_Material_Pick.ts`) · 줄 누름 = 수량 모달. +- 다이어그램 = 저장된 줄 값 + calc 답(수량 있으면 반영 · 없으면 기준 · 표기) · 알약 누름 = 입력 모달 · 자재목록 = 카드 하나 → 재료비 칸. +- 보기 갈래(고치기 없음) 부품을 냄 — D 오른쪽 패널 · H 카드가 씀. +- 입력 목록 · 값 받기 부품은 선택 입력에서 옮겨 다시 씀(`M02_Labor_Inputs.py` · `M02_Labor_Values.ts` · `M02_Labor_Values_Model.ts`) · 값 칸 규칙 한 벌(M01 넣을 값) · 로직 입력이 바뀌어 사라진 값은 알림. +- 37장 「미리 보기 값은 구조물 조합으로 안 넘김」 뒤집힘. + +## 4. 면적산출 (D · sub3) + +- 미리보기 카드 알약 줄 켬끔 단추 · 기본 꺼짐 · pref(계정) · 상세는 늘 보임(단추 없음). +- 상세 오른쪽 패널 = 그 열 일위대가의 담은 로직 + 자재목록(C 보기 갈래 · 기준 · 반영) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 지움. +- 패널 줄 누름 = 칸 지정 모달(`M02_MasterTemplete_AreaLink.ts` · [지금 고른 칸으로] · 주소 · [풀기]) → 면적 문서 `연결` · spreadsheet 손잡이 `setLink(키, 칸|null, 항목)`. +- 알약 = 로직 물량 알약 + 자재 알약(`자재|MT키` · 알약 모드 「자재」 다시 씀) · 선택 입력 알약 빠짐. +- 면적 금액 서버 계산은 1장 calc 하나(따로 안 만듦). + +## 5. 목록 · 고르기 · 카드 (H · sub1) + +- 목록 부품 하나 `M02_MasterTemplete_ItemList.ts`(M01 머리 · 쪽 · 표를 import · 옮기지 않음) — 「전체 항목」 · 복사 생성 · 항목 하나 복사가 같이 씀. +- M02 상세 제목 = M01 꼴(18px · 단추 오른쪽) 전부. +- 목록 칸: 도면 = 도번 · 이름 · 폴더 · 단위 · 고친 날 / 면적산출 = 면적번 · … / 일위대가 = 일위대가번 · 이름 · 폴더 · 단위 · 담은 로직 수 · 합계(`계산값` · 없으면 「—」) · 고친 날. +- 「새 항목」 → 상세 칸에 [새로 만들기] [복사 생성] · 복사 생성 = 같은 칸 탭 + 목록 → 지금 보는 층 · 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 일위대가 새 번호. +- 카드 = 도면 · 일위대가 · 면적산출 셋(그 차례) · 빈 카드 = [새로 만들기] [복사 생성] · 찬 카드 = 「복사해 가져오기」(창 · 탭 + 목록 → 확인 → 덮어씀 · 번호 · 열 그대로) · 일위대가 카드 몸 = C 보기 갈래. +- 탭 = 마스터 · 회사 · 개인(나) · 다른 사람(드롭다운 · 맡은 회사일 때) · 실무문서(일위대가 고를 때 · 시스템 관리자) · 기본 = 시스템 관리자 마스터 · 그 밖 개인(회사 없으면 마스터) · 탭 목록 = E `readableSources`. +- 항목 하나 복사 서버 = `copy_from` 에 from_layer · from_owner(B 가 만듦) · 원본이 실무문서 조합이면 조합 → 일위대가 문서(입력값 빈 값). + +## 6. 권한 (E · sub4) + +- 「회사 C 를 맡음」 = 시스템 관리자 · 또는 C 의 회사 관리자 → C 회사 층 · C 모든 개인 층 = 보기 · 만들기 · 고치기 · 지우기 · 복사 대상. +- 일반 = 마스터 · 회사 읽기만 · 남 개인 안 보임 · 나무 그대로 · 프로젝트 apply · sources 도 같은 규칙. +- 사람 나무 = 맡은 회사면 켬 · 회사 사람 전부(나 맨 위 · 빈 사람 「비어 있음」) · 남의 층 첫 쓰기 = 소속 확인 뒤 뼈대. +- 「〇〇로 복사…」 = 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 사람) + 대상 폴더 고르기 창(`M02_MasterTemplete_TreePanel_CopyTo.ts`). +- 남의 층 쓰기 = `system_audit_logs` 한 줄(`M02_OTHER_LAYER_WRITE`). +- 실무문서 = 일위대가 조합 길 전부 시스템 관리자만(서버가 막음). +- 새 길(labor 문서 · calc · pills · 층 넘는 복사)도 `_root` · `_layer_dir` 한 벌을 거침. + +## 7. 지우기 (G · sub6) + +- G1 자재 조합(UM) · G2 재료산출(`material/` · SM · 카드 · 시트 · 옆 판 · 길 · 시험) 먼저 · G3 선택 입력(카드 · `inputs/` · 자리 · 알약 길)은 C 의 입력 모달이 선 뒤. +- `pickMaterial` · `MaterialItem` → `M02_Material_Pick.ts` 로 옮긴 뒤 지움 · 알약 모드 「자재」 · `LinkMaterial`(A00 spreadsheet) 남김 · `고른자재.json` 남김 · 프로젝트 `입력값.json` 남김. +- storage 층(회사 · 개인 · 프로젝트)의 `material/` · `inputs/` · `자재조합.json` 파일은 지우지 않음(읽기만 끊음 · 정리는 사용자) · git 안 시스템 층 · fixture 는 지움. +- 옛 SM 번호 버림 · 장부 `재료번` 칸은 다음 집계표 [저장] 때 빠짐. + +## 8. 파일 주인 (같은 파일 = 몫만 고침 · 받은 뒤 고침) + +| 파일 | 주인 | 다른 창 몫 | +| --- | --- | --- | +| `M02_MasterTemplete_Store.py` · `M02_Template_Layers.py` | sub7 | sub6 = material · inputs 줄 | +| `M02_MasterTemplete_Router_Items.py` | sub4(`_root`) | sub7 = copy 길 from_layer · sub6 = material · inputs 길 | +| `M02_MasterTemplete_Router_Combos.py` · `Combo_Store.py` | sub5(나무 길) | sub4 = 시스템 관리자 문 · sub2 = preview 지움 | +| `M02_MasterTemplete_UI_Side.ts` | sub5 | sub6 = material · matset 줄 | +| `M02_MasterTemplete_UI_Page.ts` | sub5 | sub4 = copyTo · sub1 = onAll 이음 | +| `M02_MasterTemplete_UI_Main.ts` | sub3(면적 길) | sub2 = labor 종류 · sub1 = 제목 꼴 · showView · sub6 = material 길 | +| `M02_MasterTemplete_ItemCards.ts` | sub1(카드 셋 · 빈 카드) | sub3 = 알약 켬끔 · sub6 = 재료 · 선택 입력 카드 | +| `M02_MasterTemplete_Structure.ts` | sub3(pillsFor) | sub1 = pickCopy · sub6 = inputs · material | +| `M02_MasterTemplete_TreePanel.ts`(670줄) | sub1(copyIn · 넘으면 `TreePanel_Clip.ts`) | sub5 = 일위대가 전용 칸 지움 · sub4 = 「〇〇로 복사…」 이음 | +| `M02_MasterTemplete_Labor_*.ts` | sub2 | sub5 = `Labor.ts` · `Labor_Tree.ts` 나무 지움 | +| `M02_MasterTemplete_Api_Fetch.ts` | 각자 자기 함수만 | — | +| `A00_Common/spreadsheet/spreadsheet_links.ts` · `spreadsheet.ts` | sub3 | — | +| 공용 메뉴 · 진입(`main.ts` · `cad_host.ts` · `ui_template_context_menu.*` · `ui_template_tree.*` · `spreadsheet_condition.ts` · `spreadsheet_menu.ts`) | sub8 | — | diff --git a/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md new file mode 100644 index 000000000..fa33578c6 --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md @@ -0,0 +1,108 @@ +# 지난 계획 — 33~38장 (2026-10-01 ~ 10-02) + +단가산출 로직 점검(데스크탑 한시) · 쉬운 단가산출 · 로직 만들기 시험판(세 칸 판 · 시트 로직 · 다이어그램) · 흐름도 시험판 · 흐름도 본 프로그램 적용 · 일위대가 조합 다이어그램 · 마스터 요소 화면 손질. + +## 33. 단가산출 로직 점검 · 수정 (사용자 + 데스크탑 메인 · 보조 · 10-1 한시) + +- 사용자가 데스크탑 메인 · 보조와 직접 진행 · 브레인은 랩탑 sub1~8 로 사용자가 요청하는 다른 일을 맡음(10-1 18:00 무렵까지). +- 근거 = `docs/raw/M01_점검/종합/` · `docs/raw/M01_결정목록/` · `docs/raw/M01_입력목록/` · `docs/raw/M01_고침/`. + +## 34. 쉬운 단가산출 제어 (새 길 · 10-1 · 랩탑) + +- 사용자 확정(10-1 오전): 만든 쉬운 단가산출은 유지(지우지 않음) · 로직 정의 자체를 만드는 판은 제안만 · 허락 뒤 착수 +- 사용자 확정: 지금 단가산출은 그대로 · 「추가」 단추 옆 새 단추로 따로 들어감 · 코드는 `M01_MasterData/EasyCalc/` 한 곳(안 되면 폴더째 지움) · 초등학생도 추가 · 삭제 · 수정 · 시간이 걸려도 검토 먼저. +- 정함(브레인): 새 길은 지금 로직 형식을 그대로 읽고 씀(계산 엔진 안 바꿈) · 폴더 밖 참조는 단추 자리 한 곳만. +- [x] 34-1 검토 — 데이터 · 계산 지도(sub4) · 지금 화면 걸음 재기(sub6) · 실무 모양 + 갈래 비교(sub7) · 지난 시도(sub8) → `docs/raw/M01_쉬운단가/` · 지금 화면 새 로직 38 누름 · 5~6 창 +- [x] 34-2 설계 확정(브레인) — 문장 카드 · 품셈 기본은 「이걸 바탕으로 고치기」 · 쓰기는 `/logic/*` 만 · 1단계 숫자 수량 + 잠금 카드 → `docs/raw/M01_쉬운단가/설계.md` +- [x] 34-3a 단추 + 빈 창(sub1 · 「식으로 새로 만들기」 옆) 검증완료 +- [x] 34-3b 1단계 — Model(sub7) · Api(sub4) · Cards(sub2) · Pick(sub3) · Sheet(sub5) · 틀 · 잇기(sub1) — 설계.md 2 · 3절 · Sheet 검증완료 · Model 검증완료 · Api 검증완료 · Pick 검증완료 +- [x] 34-3c 2단계 — 표 보고 고르기(sub8) · 조건 고치기(sub7 · 조건 줄 93% 읽음) 검증완료 · 카드 단추 잇기(sub2) · 조건 고치기 검증완료 +- [x] 34-4 검증 — 대표 일 여섯 + 예외(빈 값 · 같은 이름 · 저장 안 하고 닫기 · 권한 없음 · 밝음 · 어두움) · 지우기 시험(폴더 밖 참조 줄 수) · 걸음(sub3 고침 뒤) ①21 ②8 ③2 ④4 ⑤2 ⑥3 = 40(목표 36 넘음 4 · ① 새 설계 값 자동값으로 채워 금액 곧장 보임) · 다른 일위대가 넣고 저장 됨 · 권한 없음(일반 사용자) = M01 화면 못 들어감 · /api/m01 403 · 폴더 밖 참조 두 줄 · 검증완료 + +## 35. 단가산출 로직 만들기 판 (제안 단계 · 착수 전) + +- 사용자 확정: 쉬운 단가산출처럼 따로 폴더 시험판 · 로직 몇 개 골라 만듦 · 페이지 보고 판단(마음에 안 들면 지움) · **착수는 허락 뒤** · 인터넷 조사 먼저 · 화면 용어는 단어로 다시 제안. +- 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY (세 칸: 원문 표 · 산출 과정 · 결과) +- [x] 35-0 인터넷 조사 — 견적 프로그램(sub6) · 노코드 규칙 도구(sub8) → `docs/raw/M01_쉬운단가/로직판_조사_*.md` · 제안서 2판에 반영 +- [x] 35-1 용어 단어 제안(제안서 2판 「용어 — 단어로」) → 사용자 확정 · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-2 시험판 — 사용자 허락(10-1 · 갈래가 여럿이면 갈래마다 · 목적 = 코딩 없이 원문 보며 확인 · 수정 · 생성) · A 세 칸 판 `LogicBuilder/` · B 엑셀 시트형(우리 스프레드시트) · 먼저 조사: 엑셀 로직 지도(sub4) · A 설계 초안(sub7) → 브레인 A 대 B 판단 → 구현 · 완료 처리(10-4): 35-2a 로 함 +- [x] 35-2a A 세 칸 판(가운데 문장 / 격자 두 보기) — 정함 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · Blocks(sub7) · 서버 · Api(sub4) · 원문 · 검사(sub8) · 문장(sub2) · 격자(sub5) · 단계 안내(sub3) · 틀 · 잇기 · 끝 검증(sub1) · LB 서버 검증완료 · Blocks 검증완료 · Grid 검증완료 · Steps 검증완료 · Source · Check 검증완료 · Wizard 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 원문 대조 6/6 블록 읽힘 · 걸린 줄 · 출처 보임 ② GF000219 표 칸 0.15→0.20 = 79,280→96,759 원(예상과 같음) · 파일 안 바뀜 · 다시 열면 0.15 ③ 단계 안내 11 걸음 + 저장 → 현장 다시 열림 60,984 원(/calc 같음) → 삭제 2 걸음 ④ 빈틈 해당 없음 10 · 못 따진 표 조회 1 · 기대값 시험표 맞음/다름 · 화면 초안만 ⑤ 표 고침 없을 때 lb/calc = /calc 8/8 · 문장 ↔ 격자 같은 값 · 예외(빈 이름 · 저장 안 하고 닫기 · Ctrl+Z · Ctrl+S · 권한 없음 403 · 밝음 · 어두움) · 폴더 밖 참조 LogicLab.ts 두 줄 + main.py 등록 한 줄 +- [x] 35-2b 남은 것(사용자 판단 뒤) — 문장 / 격자 고르기 · 표 칸 고침 저장 여부 · 적용 품셈 판 · 용어 · 칸 역할 「결과」 바꾸기 · 단계 안내 고르기 창 수량 물음 · GX000001(메인 11:53 · 12:02) 주인 확인 · 보고 https://claude.ai/artifact/NqKfHaJULcQo6dSvnsxy1n · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-3 점검(확인만) — 표끼리의 관계(로직) 자체를 초등학생이 만들고 · 바꾸고 · 지울 수 있나 · 품셈에 없는 관계 더하기 · 시나리오 여섯(sub6) — 결과: 있는 관계의 값만 고침(초등 중) · 관계 더하기 · 지우기 · 다시 잇기 · 새 입력값 · 새 표 · 로직끼리 바꾸기 안 됨 · 문턱만 바꾸면 계산 멈춤 + 엉뚱한 알림 · `docs/raw/M01_쉬운단가/로직판_관계제어_점검.md` +- [x] 35-4 다이어그램 제안(사용자 생각 10-1) — 상자 = 표 · 입력값 · 고정값 · 계산 · 항목 · 다른 로직 · 결과를 직접 다룸 · 선 = 관계를 만듦 · 품셈에 안 묶임(사용자 표 · 사용자 관계) · 관계 제어 = 다이어그램(사용자) · 위 가로 띠 + 아래 고른 상자 다루기 · 결과 · 관계 = 진짜 엑셀 수식(칸 주소 · 함수 · 사용자 확정) · 알약 = 입출력 칸 표시 · 새 종류 「시트 로직」(TS 엔진 한 벌 · 서버 Node) · 제안서 https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 허락 전 착수 금지 · 완료 처리(10-4): 35-5 · 35-7 로 함 +- [x] 35-5 시트 로직 시험판(사용자 허락 10-1 오후) — 위 가로 다이어그램 · 상자 = 시트 · 관계 = 엑셀 수식 · 입출력 = 알약 · 저장 = `M01_MasterData/SheetLogic/_data/`(git 밖 · 폴더째 지움) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` · Model · Eval(sub7) · 서버 · Api(sub4) · Diagram(sub8) · Panel(sub2) · Table(sub3) · Item · Result(sub5) · Import(sub6) · 틀 · 잇기 · 끝 검증(sub1) · SL 서버 검증완료 · Model 검증완료 · Diagram 검증완료 · Item · Result 검증완료 · Table 검증완료 · Import 검증완료 · 잇기 · 끝 검증완료(sub1) — 걸음: S1 ①②③ 각 3(79,280 · 73,085 손셈 같음 · 2.5m 문턱은 품셈 표 3m 부터라 2.5~3m 해당 없음) · S2 ① 3 ② 4(찰쌓기 표로) ③ 5 · S3 ① 약 19(경사 급 65,018) ② 9(내 표 붙여넣기 · 할증 10) ③ 7(잡재료 2% 17,551) · S4 선 끊기 2 · 상자 지우기 2(물음 한 번) · Ctrl+Z 되돌림 · S5 0(상자 · 선 · 값 한 장 · 고르면 이어진 선 진하게) · S6 항목 일위대가 11 · 다른 로직 상자 13(덤프트럭 16.2) · 새 로직(품셈 없이) 25 걸음 → 저장 서버 36,000 = 화면 → 다시 열림 → 지우기 2 · 서버 = 화면 4/4(36,000 · 17,550.9 · 10.12 · 다시 열기) · 가져오기 6/6 /calc 같음(0 · 60,984/79,280 · 2,064,816 · 670 · 16.2 · 57,516) · 예외(빈 이름 · 같은 상자 · 순환 · 빈 입력 물음 · 저장 안 하고 닫기 · 권한 없음 sl 5/5 403 · 밝음 · 어두움) · 세 칸 안쪽 세로 밀기 없음(6개 모든 상자) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 남은 흠(다른 창): 가져온 표 상자 + 결과 칸 목록 예시값이 글자로 들어가 30 등에서 해당 없음(sub5) · 가져온 로직 화면 번호 · 자재 검색어 · 끝 검증완료(S1~S6 · 새 로직 · 서버 = 화면 4/4 · 가져오기 6/6) · 남은 흠: 결과 칸 예시값 선택이 글자 「30」 으로 들어가 표 찾기 해당 없음(sub5 · 사용자 지시 뒤) +- [x] 35-5a 사용자 요청 — 다이어그램 띠 · 고른 상자 시트 · 결과 높이를 안쪽 세로 스크롤이 없을 때까지(sub1) +- [x] 35-6 로직 문법 먼저(사용자 방향 10-1 14시 무렵 · 화면보다 로직) — 바탕 표(인력 378 · 재료 30,767 · 기계 654 · 소요량 2,535 · 계수 498 · 환율 · 요율 · 사용자 표 — 보기 · 만들기 · 비목은 표가 정함) · 단위 넷(값 · 표 · 정보 · 요소) + 묶음(세분화 · 묶기 · 분류 · 묶음째 잇기 · 겹 묶음 · 틀 다시 쓰기) · 흐름 셋(정보 만들기 · 잇기[그대로 · 바꿔서 · 갈라서 · 모아서 · 묶음째] · 내기) · 확인 도구 · 제안서 https://claude.ai/artifact/BFWvm1Q4fhCSZ4Rt34UgYY · 다음 = 문법 확정 → 종이 연습(절 10개) — 허락 전 착수 금지 · 완료 처리(10-4): 35-7 · 35-9 흐름도로 갈음 +- [x] 35-5b 단추 공용 양식(점검 끝 · 고침은 사용자 지시 뒤) — 날 단추 23(목록 줄 8 · 칩 · 역할 · 보기 바꿈 11 · ▾ 2 …) · sm 단추를 44px 로 덮기 3 · 큰 시작 56px 3 · `docs/raw/M01_쉬운단가/시험판_단추_점검.md` · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-7 단가산출 다이어그램 시험판(사용자 기준 10-1 14시 무렵) — 입력 · 함수 · 출력 상자 · 자유 배치 · 함수 = 엑셀 · 출력 하나 · 출력 = 비용 종류 · 원가 · 폴더 `M02_MasterTemplete/CostFlow/`(_data · _sample) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` · Model · Eval(sub7) · 서버(sub4) · Diagram(sub8) · 함수 판(sub2) · 입력 판(sub3) · 출력 · 원가(sub5) · 견본(sub6) · 단추 · 페이지 · 잇기 · 끝 검증(sub1) · Model 검증완료 · Output · Summary 검증완료 · Sample 검증완료 · FuncPanel 검증완료 · Diagram 검증완료 · CF 서버 검증완료 · InputPanel 검증완료(사용자 요소 만들기는 서버 Api 가 main 에 오면 확인) · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 손으로 메쌓기 꼴(변수 4 · 소요량 표 2 · 인력 2 · 함수 증가율 → 석공 / 보통인부 병렬 · 출력 노무비 2) 9 상자 · 약 138 손놀림 → 원가 148,471 = 견본(증가율 40 · 석공 97,882.512 · 보통인부 50,587.992 손셈 같음) ② 비용 종류 바꾸면 148,471 이 노무비 → 경비 → 재료비 로 옮겨감 · 「값」 이면 원가에서 빠짐(각 1 걸음) ③ 상자 끌어 옮김(1520,190 → 1675,306) · 저장 → 다시 열면 그대로 ④ 함수 출력 하나(다른 칸 고르면 옮겨감 · 알약 하나) · 순환 막힘 · 출력에서 나가는 선 없음 · 빈 받는 자리는 저장 때 물음 ⑤ 사용자 요소(시험 보조공 200,000) 5 걸음 · 마스터 그대로 · _data 에만 ⑥ 저장 때 서버 = 화면 2/2(148,470.504) ⑦ 단위 오른쪽 위(- → 원/㎡) · 색 바꾸기(Entry 판 머리) · 「다이어그램」 이름 · 다이어그램 726px · 안쪽 스크롤은 함수 격자(가상 200줄)만 · 밝음 · 어두움 · 권한 없음(cf 길 6/6 403 · 화면 못 들어감) · 견본 셋 = /calc · 손셈(148,471 · 3,000 · 15.552) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 끝 검증완료(손으로 메쌓기 9 상자 = 견본 148,471 · 비용 종류 옮김 · 자리 저장 · 규칙 · 사용자 요소 · 서버 = 화면 · 견본 셋 손셈) · 함수 출력 기본 이름 겹침 고침(sub7 2be767d18) · 남은 것: M01 단가산출 단추 줄이 셋째 줄까지(시험판 단추 넷 · 사용자 결정) +- [x] 35-7a 브레인 실제 검증(10-1 16:45) 흠 여섯 고침 — ① 고칠 것 칸 지난 경고 · ⑥ 시험판 단추 「시험판 ▾」 로 묶기(sub1) · ② 알약 쉼표 · ③ 표 알약 글 · ⑤ 맞춤 확대 90% 아래 안 감(sub8) · ④ 입력 상자 공통 단위(sub3) · 다시 검증완료(16:55 · 고칠 것 칸 비어 있음 · 알약 쉼표 · 표 · 40줄 · 인력 원/일 · 확대 90% · 막대 한 줄 · 시험판 ▾ 넷 · 원가 148,471) + +## 35-8. 단가산출 다이어그램 2판 — 재료 선택 자리 · 선 규칙 (계획 · 허락 전) + +- 사용자 확정: 고르는 방식은 재료만(다른 그룹은 상자를 늘림) · 설계 때 입력은 오른쪽 아래 검증 칸 · 나중에 프로젝트에서 일위대가로 쓸 때 재료는 목록에서 고르거나 단일 값 · 표에 따라 출력을 가르는 것은 엑셀 수식 대신 선 규칙. +- 정함(브레인): 재료 요소 = 고정 · 선택 자리 · 조건 찾기 · 알약 이름 = 자리 이름 · 선 규칙 = 선 하나에 하나(표에서 찾기 · 조건 통과 · 바꾸기) · 규칙은 받는 칸 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임. +- [x] 35-8a 틀(Model) — 재료 요소 방식 · 범위(구분 · 상세구분 · 찾는 말) · 조건(규격 ← 알약) · 기본 품목 · 선 규칙 칸 · 규칙 → 받는 칸 수식 · 조건 선 겹침 · 안 맞음 검사 — node 시험(sub7) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8b 서버 — 선택 자리 기본 품목 · 조건 찾기 단가(맞음 0 · 여럿 알림) · 「설계 때 정할 것」 목록(변수 + 재료 자리) 내보내기 — 시험(sub4) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8c 입력 판 — 재료 방식 · 범위 · 조건 규칙 · 기본 품목 · 찾기 고침(쪽 넘기기 · 띄어쓰기 AND · 규격까지) — ORCA(sub3) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8d 검증 칸 — 재료 선택 자리를 변수처럼(범위 안 목록 고르기 · 찾기) · 조건 자리는 잡힌 품목 표시 — ORCA(sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8e 선 규칙 — 선 위 규칙 글 · 선 누르면 아래 판에서 규칙 고르기(표 찾기: 조건 칸 ↔ 알약 · 꺼낼 칸 / 조건 통과: 알약 · 같음 · 다름 · 이상 · 이하 · 값 / 바꾸기: × · ÷ · +% · 단위 · 끝수) · 고른 상자 판에 들어오는 선 규칙 목록 — ORCA(sub8 · 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8f 견본 · 끝 검증 — 견본 둘 더(지반에 따라 굴삭기 / 브레이커 가르기 · 흄관 관경 조건 찾기) · 사용자처럼: 함수 상자 없이 표 찾기 · 가르기 · 검증 칸에서 재료 고르기 · 방법 걸음 수(sub6 견본 · sub1 잇기 · 끝 검증) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8g 다이어그램 마우스 — 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자가 선택되지 않음(다이어그램 영역 글자 선택 막음) · 상자 옮기기는 상자 머리 왼쪽 끌기 그대로 — ORCA(sub8) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8h 「고른 상자」 → 「상세 내용」 · 상자뿐 아니라 알약 · 선을 골라도 내용이 바뀜(알약 = 값 · 단위 · 어디서 오나 · 어디로 가나 / 선 = 선 규칙 고르기) — ORCA(sub1 잇기 · 알약 · 선 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8i 상세 내용 단추 · 칸 크기 — 함수 판 꾸밈이 공용 작은 단추를 44px 로 덮는 줄(`M02_CF_FuncPanel.css` `.m01-cfpanel .ui-btn …`)을 지우고 판 안 단추를 모두 공용 작은 크기(createButton size "sm")로 · 입력 · 고르기 칸도 공용 크기 — ORCA 수치(sub2 · sub3 · sub5 각자 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8j 상세 내용 머리 — 왼쪽 「상세 내용」 · 오른쪽에 고른 상자 제목 · 알약을 골랐으면 그 오른쪽에 알약 이름(예 「높이 증가율 찾기 › 증가율」) · 상세 내용 안 칸 차례 정리(머리 · 상자 설정 · 본문 · 경고) — ORCA(sub1) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8k 단위는 고르기 — 단위 칸을 목록 고르기로(치면 걸러져 보이고 고르기는 사용자 · 손으로 쓴 글은 안 받음) · 목록 = 단위 표준(35-8l) 단위 + 마스터 결과단위 + 금액 단위(원) · 「-」 = 없음 · 함수 · 출력 · 변수 · 사용자 요소 단위 모두 같은 고르기 — 공용 고르기 파일 `M02_CF_UnitPick.ts`(sub2) · 판마다 바꿔 끼움(sub2 · sub3 · sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8l 단위 표준 표 — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` · 종목 약 70 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③)을 표로 — 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 흠은 사용자가 고침(시험판에서 보고 고치는 표 · 저장 `CostFlow/_data` · 처음 값 `CostFlow/_standard/`) — 시험(sub6) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8m 금액 단위 표준 — 같은 절 「금액의 단위표준」(설계서 총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 모두 미만 버림)을 같은 표 자리에 — 출력 상자(비용) 금액 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림 · 바꿀 수 있음(sub6 표 · sub5 출력 · 원가) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8n 끝수 고르기 — 함수 상자 출력 · 출력 상자에 「끝수」(소수자리 · 반올림 / 올림 / 버림) · 단위를 고르면 단위 표준에서 소수자리를 제안(주 ① 반올림 · ② 항목 값이 우선) · 고른 끝수는 출력 칸 수식(ROUND 계열)으로 — 엔진 한 벌(sub7 틀 · sub2 함수 판 · sub5 출력 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8o 로직끼리 잇기 · 흐름 따라가기 — 저장한 다른 로직을 입력 상자로 불러 그 출력 알약을 받음(일위대가 → 일위대가) · 알약 · 선을 고르면 값이 어디서 와서(출처 사슬) 어디로 가는지(가는 곳) 다이어그램에서 길을 칠하고 상세 내용에 줄로 · 값이 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건) 표시 — 시험 · ORCA(sub7 틀 · sub8 다이어그램 · sub1 상세 내용) · 완료 처리(10-4): 35-9 에서 함 +- 차례(사용자 10-1 · 「데이터의 흐름과 값의 연결에 집중」): **1단계 = 흐름 · 연결** a 틀 · b 서버 · e 선 규칙 · o 로직끼리 · 흐름 따라가기 · h 알약 · 선 고르기 · c · d 재료 자리 · f 견본 · 끝 검증(흐름 기준 — 값이 어디서 와서 어떤 규칙을 지나 어디로 가는지) / **2단계 = 값의 뜻 · 보기** k 단위 고르기 · l 단위 표준 · m 금액 표준 · n 끝수 · j 머리 · g 마우스 · i 단추 + +## 35-9. 단가산출 흐름도 시험판 (사용자 지시 10-1 17시 무렵 · 브레인이 끝까지 · 35-8 전체) + +- 사용자 확정: 브레인이 전체 제어해 새 시험판 하나 · 단가산출 다이어그램(시험)만 남기고 옛 시험판 셋 삭제 · 새 폴더(필요 없으면 바로 지움) · 데이터 흐름 · 값 연결 먼저 · 자유도 높고 이해하기 쉬운 화면 · 끝까지 구현 · 검증. +- 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` · 폴더 `M02_MasterTemplete/DataFlow/` +- [x] 35-9 창 여덟 — 옮기기 · Model · Eval(sub7) · 옛 시험판 지우기 · 들어가는 길 · 상세 내용 머리 · 잇기 · 끝 검증(sub1) · 서버(sub4) · 다이어그램(sub8) · 상세 내용 판 · 단위 고르기(sub2) · 입력 판(sub3) · 출력 · 검증 칸(sub5) · 표준 표 · 견본(sub6) · 표준 · 견본 검증완료 · Model 검증완료 · 출력 · 검증 칸 검증완료 · Diagram 검증완료 · 입력 판 검증완료(서버 자재 찾기 · UnitPick 이 main 에 오면 다시 확인) · DF 서버 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 함수 상자 없이 네 흐름 73 걸음: 재료 조건 찾기(관경 → 흄관 자리 · 13 개 중 하나 · 125,870) 21 · 선 규칙 표에서 찾기(높이 증가율표 → 출력) 19 · 조건 통과 두 갈래(지반 = / ≠ 토사) 27 · 다른 로직 잇기(견본 지반 · 지반 알약 이음) 6 ② 알약 · 선 고름 → 온 길 · 가는 길 칠함 + 상세 내용 줄(지반 → 다른 로직 · 두 조건 선 · 흄관 ← 관경) ③ 끊긴 곳 넷 보임(빈 받는 자리 · 통과 안 됨 · 표에 맞는 줄 없음 · 맞는 재료 없음 — 알약 · 선 「!」 · 끊긴 곳 줄 누르면 그 자리 고름) ④ 검증 칸 관경 600 → 800 = Φ800 품목 215,530 · 지반 토사 → 암 = 내 두 갈래 뒤집힘 + 다른 로직 2,000 → 10,000 · 다른 로직 설계 값 바꿈 10,000 → 20,000 ⑤ 단위 고르기 「mm」 = 3 개로 걸러짐 · 손글 안 받음 · 끝수 80,000.37 = 기본 225,870 / 1원 올림 225,871 · 표준 표 고침 저장(3 → 2) → 처음 값으로 되돌림 ⑥ 가운데 끌기 = 판 옮김(−60, −30) · 왼쪽 끌기 = 판 그대로 · 글자 선택 막힘 · 단추 18px 모두 sm · 칸 30px ⑦ 저장 → 다시 열기 = 상자 9 · 선 6 · 자리 · 규칙 글 그대로 · 서버 다시 셈 = 화면 187,910 · 견본 다섯 사본 저장 서버 = 화면(148,470.504 · 2,000 · 84,680 · 3,216.6 · 값 15.552) · 밝음 · 어두움 · 로그인 없음 df 길 6/6 401(관리자 아닌 계정 403 은 계정 없어 못 함) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 검증 중 고친 내 흠(047f3a279 · 28469de62): 재료 조건 찾기 · 선택 자리 값을 서버에 안 물음(흄관 견본 관경 600 → 400 = 211,700 → 116,600 · 1m 84,680 → 46,640 · 1000 = 235,120 · 선택 자리 Φ600 → Φ400 = 211,700 → 116,600) · 「상자 이름」 칸(입력 · 함수 · 같은 이름 · 빈 이름 막음 · 출력은 출력 판) · 머리 이름 · 단위 칸 작은 크기(30px) · 「새로 만들기」 단추 76px(칸 폭 710 아님) · 표준 표 모달 제목 한 번 · 남은 흠(sub2): 선 규칙 표에서 찾기 조건 칸에 범위 이름(「높이」)이 없어 범위 찾기를 화면에서 못 만듦(높이 5 → 줄 없음 · 4 만 맞음) · 브레인 실제 검증완료(흠 14 고침 · 흐름도 시험 81 · 견본 다섯 서버 = 화면 · 보고 JgfqRzLPYgqXG7vB2cV1Yj) · 검증 대기 + +## 36. 단가산출 흐름도 본 프로그램 적용 (사용자 지시 10-1 21:40 · 결정 반영 22시 · 착수 승인 22:20 · 밤새 진행 · 아침 보고) + +- 사용자 확정: 흐름도 = 최종 · 로직 목록에서 로직을 열면 지금 내용 그대로 + 오른쪽 흐름도 패널(같은 로직) · 「식으로 새로 만들기」 지우고 「신규 로직」(흐름도 새로 만들기) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽) · 시험판 단추 · 입구 지움 · DataFlow 폴더 유지. +- 근거(마스터 로직 57 파일 · 1,472 로직 · 입력 3,518 · 중간 식 3,168 · 호표 줄 5,265): 식 말 = 찾기(표 · 조건 · 칸) 8,152 · 만약 3,532 · 또는 185 · 최대/최소 173 · 올림/반올림/버림 141 · 로직() 41 → 흐름도 표에서 찾기 · 조건 · 함수 · 다른 로직 입력으로 옮길 수 있음. +- 사용자 결정 D1(그대로): 「품셈원문도 제대로 구현된게 아니기 때문에 내가 흐름도까지 만들어서 로직을 볼려는 거잖아. 그러니 당연히 둘다 같은 내용 … 원문과 대조하면서 검증할때 잘못된게 있으면 흐름도에서 수정할거야」 → 한 로직 · 두 보기: 왼쪽 지금 내용 = 보기 · 고치기는 흐름도(공용 로직 포함 · 시스템 관리자) · 흐름도로 저장한 로직은 흐름도가 정본 · 왼쪽은 흐름도에서 만든 같은 꼴 · 아직 저장 안 한 로직은 지금 내용에서 자동으로 그린 흐름도(값 대조로 같음). +- 사용자 결정 D2(그대로): 「흐름에 대한 로직은 costflow폴더에 있을테니 일단은 그냥 두고 쓸대없는 견본과 버튼만 삭제」 → CostFlow 폴더 그대로 · 시험판 단추 · 견본 목록 지움(흐름도는 DataFlow 폴더만으로 돌아감 · CostFlow 를 부르지 않음). +- 사용자 결정(함수 상자): 「함수박스를 기준으로 하나의 결과가 나가는게 맞는것같아. 여러개가 동시에 나가면 혼란만 가중」 → 함수 상자 하나 = 결과 알약 하나(그대로) · 식은 상자 겉에 넣지 않음(상자가 커짐) · 함수 상자 · 선을 고르면 상세 내용에 사칙연산 · 조건이 보이고 고치면 바로 따라감. +- 칸 연결 = M02 방식 그대로(칸을 고른 채 알약을 눌러 하나씩 연결 · 식은 칸 주소 · `spreadsheet_links.ts` 직접 확인) · 계산 엔진 · 이름 풀기 · 이름 규칙 없음 — 보기만 칸 주소를 연결된 알약 이름으로 바꿈. +- 사용자 결정(조건): 「조건문은 현재를 유지하는게 보기 편한것같고 대신 어떤값을 지정할지는 입력된 리스트에서 선택 … 조건 값을 내가 넣으면 오기 … 복수 조건문도 넣어주고」 · 「조건 만들기도 엑셀 기능 내부에서 존재해야해」 → 선 조건은 지금 웹앱 모양 · 조건 값은 고른 알약이 가질 수 있는 값 목록에서 고름(변수 고르기 · 표 칸 값 · 재료 규격 등 · 목록이 없는 수는 숫자 칸 + 범위 검사) · 조건 여러 줄(그리고 / 또는) · 함수 상자 엑셀 시트 안 「조건 만들기」(칸 고름 → 만약 · 아니면 만약 · 아니면 줄 → 그 칸에 IF 식 · 값은 같은 목록에서 고름). +- 안전: 화면 시험 저장은 마스터 사본 폴더에서만(서버가 환경 변수로 사본 폴더를 씀 · 실제 resources/master_data 는 안 바뀜 · git status 로 확인). +- 창: sub2 36-0(판 · 조건 만들기) · sub7 36-0b(조건 틀 · 값 목록) → 36-2 · sub6 36-1 → 36-7 · sub8 36-3 · sub4 36-4 서버 · 사본 폴더 · sub5 36-4 왼쪽 보기 · sub1 36-5 · 잇기 · 36-8 · sub3 36-6 +- 브레인 정함(조사 뒤 · 아침 보고에 알림): 옛 로직 「기계 · 직종 · 유가 자리」(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 입력값으로 요소를 고름 · 71 로직)는 새 모드 없이 이미 있는 「조건 찾기」(이름 · 키 같음 · 변수 알약)를 인력 · 기계 · 유가 요소에도 씀 · 대조 입력 = check_master.시험입력 · 로직 셈을 부르는 곳(Store.calc · text · master_combo.run · M02_Item_Prices.calc · /logic/auto)은 한 곳에서 흐름 갈래. +- [x] 36-0 함수 상자 안 보기 · 칸 연결 조정(36-2 보다 먼저) — ① 상세 내용 「식 보기」: 결과 칸 식을 칸 주소 → 연결된 알약 이름으로(예: 1m 재료비 = 흄관 단가 ÷ 2.5) + 값 들어간 식(= 211,700 ÷ 2.5 = 84,680) · IF 는 만약 / 아니면 줄 · 지금 타는 갈래 표시 · 중간 칸 식도 차례대로 · 칸 식을 고치면 바로 다시 셈 → 다이어그램 · 원가 따라감 ② 「칸 연결 표」(칸 · 연결 알약 · 입력 / 결과 · 이어진 선 · 표에서 바로 바꿈 · 풀기) + 칸 색(입력 파랑 · 결과 초록 · 상자 색과 같음) · 결과 칸 하나 ③ 선을 고르면 그 선 규칙도 같은 「식 보기」 꼴(예: 굴착량 → 지반 = 토사 일 때만 → 굴삭기로 파기) ④ 엑셀 시트 안 「조건 만들기」 — 칸을 고르고 열면 만약 [알약] [비교] [값 고르기] → [알약 · 값] 줄 · 아니면 만약 더하기 · 아니면 → 그 칸에 IF 식(엑셀로 쓴 IF 도 같은 줄로 다시 열림 · 못 바꾸는 식은 식 그대로) · 검증완료(sub2 · 식 보기 · 칸 연결 표 · 조건 만들기 · 선 조건 여러 줄 · 시험 8) · 검증 대기 +- [x] 36-0b 선 조건 손질 — 조건 값 = 고른 알약의 값 목록에서 고름(Model 값 목록 함수 · 변수 고르기 · 표 칸 값 · 재료 규격 · 목록 없는 수는 숫자 칸) · 조건 여러 줄(그리고 / 또는) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 위 이름표 · 겹침 · 안 맞음 검사 그대로 · 검증완료(sub7 · 조건 여러 줄 그리고/또는 · 값 목록 · 서버 = 화면 · 시험 30) · 검증 대기 +- [x] 36-1 조사 — 옛 식 문법 전수(서버 파서 · 요소 부르기 꼴 · 따옴표 글) · 변환 못 하는 꼴 목록 · /calc 를 부르는 곳 · 검증완료 +- [x] 36-2 변환 — 옛 로직(입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수) → 흐름도(입력 상자 · 표 알약 + 표에서 찾기 · 함수 · 출력 상자) + 자동 배치 · 서버 파서로 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(만약 → IF · 최대 → MAX · 찾기 → 표에서 찾기) · 함수 상자 하나 = 결과 하나 · 전수 대조(변환 흐름도 값 = 옛 계산 값 · 맞는 비율 · 안 맞는 까닭) · 검증완료(sub7 · M02_DF_Convert · 전수 1,472 시험입력 96.7% · 기본 입력 97.4% · 안 맞는 49 = 재료 검색어 자리 18 · 재료 빈 검색어 13 · 표 범위 규칙 경계 15 · 단가 빈 품목 3 · tmp/convert_report_36.json) · 검증 대기 · 마지막 대조: 시험입력 · 기본 입력 모두 1,472/1,472(100%) · 빈 단가 = 옛 셈 뜻(그 값 쓰는 식 빈 값 · 비목 합 0 · 끊긴 곳 「단가 없는 품목을 씀」) +- [x] 36-3 오른쪽 패널 — 로직 상세 오른쪽에 흐름도(다이어그램 · 상세 내용 · 원가 · 검증) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면) · 검증완료 +- [x] 36-4 저장 · 정본 — 로직 줄에 「흐름」 칸(화면 [저장] 때만 씀) · 흐름이 있으면 /calc 가 흐름도로 셈(서버 다시 셈) · 왼쪽 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산은 흐름도에서 만든 같은 꼴(보기) · 공용 로직도 흐름도로 고침(시스템 관리자) · 캐시 → [저장] 한 번 · [고친 것 버리기] 함께 · 왼쪽 보기 검증완료(sub5) · 서버 검증완료(sub4) +- [x] 36-5 단추 — 「식으로 새로 만들기」 지움 → 「신규 로직」(빈 흐름도 + 기본정보 · 저장 = 자체 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(흐름도도 복사 · 신규 로직 오른쪽) · 「시험판 ▾」 지움 · 잇기 검증완료(sub1 · accf8ecc4 · abbf61971 · 0d45014e3) — 「식으로 새로 만들기」 · 시험판 ▾ · 시험판 단추 둘 지움 · 단추 차례 ← 로직 목록 · 신규 로직 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 · 두 칸(LogicLab_Flow) · 오른쪽 패널(흐름 받기 · 고침 → drafts → [저장] 한 번 · 버리기 함께 · 흐름 로직이면 왼쪽 흐름 보기) · 신규 로직 기본정보 칸(LogicLab_Basic) → 저장 = 서버 added 키로 엶 +- [x] 36-6 다른 로직 입력 — 대상 = 마스터 로직 전체(찾기) · 흐름도 로직이면 흐름도 셈 · 아니면 옛 셈 · 목록 · 찾기 · 쪽 · 알약 이음 검증완료(서버 흐름 받기 · 값 셈 길이 main 에 오면 GF 로직 값 확인) +- [x] 36-7 정리 — 「시험판 ▾」 · 시험판 입구 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 resources/tester 로 · 시험 저장 길 숨김 · DataFlow · CostFlow 폴더 그대로 · 검증완료 +- [x] 36-8 끝 검증 — 흐름 기준 · 옛 로직 열기 → 오른쪽 흐름도 값 = 왼쪽 계 · 신규 로직 → 저장 → 다시 열기 · 복사 · 권한 · 브레인 실제 검증 · 보고 · 끝 검증완료(sub1 · 사본 폴더 tmp/master_copy_36 + AISLO_MASTER_DIR · 네 포트) — ① 옛 로직 셋: GF000001(찾기) 오른쪽 34,413 = /calc 34,413.6 = 왼쪽 34,414(보임 끝수만 다름) · 변환 알림 한 줄 / GF000007(만약) 0 ≠ 597,285 · GF000288(로직()) 0 ≠ 1,234,760.96 — 아래 흠 ② GF000001 함수 식 ×1.5 → 오른쪽 · 왼쪽 51,620 따라감 → [저장](공용 GF · 사본) → 다시 열기 상자 6 · 변환 알림 없음 · /calc flow 51,620.4 = 화면 ③ 신규 로직 65 걸음(변수 셋 · 함수 · 조건 만들기 · 출력 둘 · 조건 선) → 저장 GX000004 → 다시 열기 상자 4 · 선 4 · 화면 100,000 = /calc 100,000 = 왼쪽 계(높이 2 = 0 · 지반 암 = 50,000) ④ 로직 복사/생성 2 걸음 → GX000005 상자 4 · 선 4 · 100,000 ⑤ 선 조건 두 줄 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 값 고르기(토사|암) · 높이 2 → 0 · 또는 → 50,000 · 조건 만들기 =IF(A2>=3,A3,0) ⑥ 식 보기 「결과 1 = 금액 × 높이 ÷ 4」 · IF 만약 / 아니면 줄 · 칸 연결 표 ⑦ 크게 보기 1484×1076 · 거기서 고친 것 패널로 · 접기 40px · 펴기 · 너비 320 → 522 ⑧ 고친 것 버리기 5 → 4 · 캐시 비움 · 로그인 없음 flow · save · calc · logics 401 · 시험판 · 옛 단추 글 0 · resources/master_data 바뀜 0 · 흠(다른 창): 변환 숫자 입력 예시값이 비어 있음(시험입력 · 기본 입력을 안 넣음 — GF000288 나무높이 6 · 집재거리 100 · GF000007 정리량 50 …) · GF000007 「표 QF000033 → 할증」 표에 맞는 줄 없음(서버 477,828 = 할증 25% 빠짐 · 화면 0) · GF000288 다른 로직 GC000270 기계 자리 「EQ:{기계} … 자리」 못 풂(#N/A → 0) · 칸 #N/A 일 때 화면 셈 0 · 서버 셈 그대로(둘이 다름)(sub7) · 식 보기 값 줄이 선 규칙을 지난 값을 안 씀(「(#N/A × 0) = #VALUE!」 · 결과 알약은 34,413.6)(sub2) · 조건 통과 둘째 줄 값 목록 「number:0」 글 · 조건 만들기 만약 목록에 놓인 알약 없음(sub2) · 옛 로직 왼쪽 계 반올림 · 흐름 원가 계금 1원 버림(1원 차이) +- 36 마무리(10-2 02시 · 브레인 실제 검증완료): 전수 변환 대조 시험입력 1,469/1,472(99.8%) · 기본 1,466(99.6%) · 남은 3 = 마스터 빈 단가 · 시험 141 + 변환 시험 초록 · 사본 서버 저장 → 사본만 바뀜 · 다시 열기 흐름 정본 · GF000001 34,414 = 34,414 · GF000003 8.946 = 8.946 · 식 보기 값 줄 · 검증 대기 + +## 37. 일위대가 조합 다이어그램 (사용자 지시 10-2 09시 · 계획 · 착수) + +- 사용자 확정: 「복잡한 경우가 있으니 일위대가 조합도 다이어그램으로 표현 … 중요하게 생각하는 정보가 다르니 잘 구분」 · 조합은 계산하지 않고 로직을 뭉쳐 비용 구분별로 표기 · 입력은 로직별로 묶어 빈 값은 사용자가 넣음 · 출력은 로직마다 · 미리 보기 값은 구조물 조합으로 안 넘김 · 「흐름도는 별도 로직이 아님」(단가산출 로직을 쓰는 방법). +- 모양: 왼쪽 = 로직별 입력 묶음(받아야 할 값 · 빈 값 표시) → 가운데 = 로직 카드(키 · 이름 · 결과 단위 · 식 · 함수 안 보임) → 오른쪽 = 비용 구분 칸 셋(노무비 · 재료비 · 경비 · 로직마다 선으로 모임) + 값 로직은 「값」 칸 · 이름이 같은 입력(지역 · 자재지역 등)은 점선으로 이어 보이기만(검사 없음) · 로직 카드를 누르면 그 로직의 단가산출 로직 화면(흐름도)으로 · 계산 · 고치기 없음(묶음 고치기는 지금 표) · 흐름도와 모양 · 색을 달리함. +- 자리: 일위대가 조합 상세(M02_MasterTemplete_Labor_Edit)의 미리 보기와 같은 값 — 미리 보기 입력 칸에 넣은 값이 다이어그램에도 · 다이어그램 빈 입력 알약을 누르면 미리 보기 그 칸으로 · 미리 보기 결과(로직별 노무비 · 재료비 · 경비 · 계)가 카드 · 칸에. +- [x] 37-1 다이어그램 판 — M02_MasterTemplete_Labor_Diagram.ts(+css) 새 파일 · 고정 세 칸 배치(자동) · 판 옮기기 · 확대 · 한눈에 · 밝음 · 어두움 · 좁은 폭 +- [x] 37-2 잇기 — Labor_Edit 에 「다이어그램」 칸(접기) · 미리 보기와 값 한 벌 · 빈 입력 누름 → 미리 보기 칸 · 로직 카드 누름 → M01 단가산출 로직 그 로직 열기(setPendingLogicKey 꼴) +- [x] 37-3 검증 — 조합 셋(로직 2 · 9 · 14개)으로 입력 묶음 수 = 로직 입력 수 · 출력 = 미리 보기 줄 · 비용 칸 합 = 미리 보기 합 · 빈 값 표시 · 같은 이름 점선 · 로직 열기 · 브레인 실제 검증 · 보고 +- 창: sub2 37-1 · 37-2 · 37-3 + +## 38. 마스터 요소 화면 손질 (사용자 지시 10-2 09:40 · 착수) + +- 사용자 확정: ① 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 상세의 제목 · 단추 · 찾기 라벨 양식 = 단가산출 로직과 같게 ② 재료 컨테이너의 유가 · 전력을 별도 컨테이너로 ③ 단가산출 로직 「막힘만」 체크 — 의미 없으면 지움 ④ 단가산출 로직 컨테이너 「전체」 를 누르면 상세 목록 보기가 안 됨(다른 컨테이너는 됨). +- 브레인 정함: 「막힘만」 = 로직 검사에 걸린 로직만 거르기 · 지금 1,469 중 0 · 목록 줄에 「⛔ 막힘」 표시 · 까닭 말풍선은 이미 있음 → 체크만 지움(표시는 둠). +- [x] 38-1 양식 맞춤 — 일곱 상세 화면 머리(제목 · 단추 줄 · 찾기 칸 라벨)를 단가산출 로직 화면 꼴로 · 공용 단추 · 칸 크기(sub5) · 검증완료 +- [x] 38-2 유가 · 전력 컨테이너 — 왼쪽 패널에 재료 다음 「유가 · 전력」 컨테이너(재료_유가전력.json) · 재료 컨테이너에서는 뺌 · 고르기 · 찾기 · 상세 그대로(sub6) · 검증완료 +- [x] 38-3 「막힘만」 체크 지움 · 38-4 「전체」 목록 보기 고침(sub1) · 검증완료 — 원인: 로직 컨테이너 거름이 목록만 거르고 가운데 화면 전환(onLogic)을 안 부름 → Side 거름 run 에서 로직이면 onLogic · 「전체」 는 찾는 글도 비움 · 화면(5174): 로직 1,469 줄 → 인력 전체 → 로직 「전체」 = 로직 화면 1,469 줄(찾는 글 「굴착」 비움) → 산림품셈 330 → 03장 작업장 관리 10 → 로직 열기 → 「전체」 = 상세 닫힘 · 1,469 줄 · 막힘만 체크 0 · 화면 오류 0 +- 「고친 것 버리기」(사용자 물음 · 확인만): 저장 안 한 고친 것(화면 캐시)을 버리고 파일의 로직을 다시 엶 — 사본 저장은 없음 · 그대로 둠(사용자 결정 기다림) From 6c28357fb74c4055da8334561735c966457fedd9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:19:20 +0900 Subject: [PATCH 02/36] auto: 2026-10-04 17:19 (ESD_LAPTOP) --- .../raw/M02_구조물조합개편/사용자요구_1004.md | 50 ++++++++ docs/raw/M02_구조물조합개편/설계_정함.md | 105 +++++++++++++++++ ...가산출_흐름도_조합다이어그램_마스터요소.md | 108 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004.md create mode 100644 docs/raw/M02_구조물조합개편/설계_정함.md create mode 100644 docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004.md b/docs/raw/M02_구조물조합개편/사용자요구_1004.md new file mode 100644 index 000000000..936caa071 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004.md @@ -0,0 +1,50 @@ +# M02 구조물 조합 개편 — 사용자 요구 (2026-10-04 · 원문 그대로) + +1번(계획서 32장 이후 정리)은 브레인이 처리함(c8269a969). 7 · 8번은 원문에 없음. + +## 2. 일위대가 조합 → 구조물 조합 하위 + +M02페이지의 마스터 템플릿 페이지에서 일위대가 조합은 구조물 조합의 하위 컨테이너로 넣을 것.(구조물 도면과 면적산출 사이에 배치) +- 일위대가 조합의 좌측 패널의 기존 보기 방식인 트리구조는 필요없으며 구조물 도면 처럼 리스트형태로 보임 +- 추가로 구조물 도면, 일위대가, 면적산출의 최상위에는 전체항목을 넣어주고 우측 상세에는 리스트로 보여줌, M01 항목을 보면 양식화되어 있는 페이지 헤이아웃 있음 + +## 3. 이름 + +이동한 일위대가 조합은 일위대가로 명칭 변경 + +## 4. 구분선 + +구조물 조합 컨테이너 내부의 서브 컨테이너 항목과 트리구조 사이에 수평 구분선 추가 + +## 5. 사용자별 구조물 조합 읽기 / 쓰기 / 복사 권한 점검 및 개선 + +- 시스템 관리자 : 아이슬로 프로그램 관리자로 마스터를 제공하여 회사관리자나 일반 사용자가 사용하기 쉽게 한다. +- 시스템관리자가 대시보드의 컨테이너 제목의 우측의 회사명을 선택함에 따라 변경한 목적은 해당 회사의 정보를 접근하고나 AS을 목적으로 고객을 도와주기 위함임. 복사항목에는 마스터나 시스템 관리자 회사, 개인으로 들어갈 항목 뿐만 아니라 고객 회사의 특정 폴더로 복사할 수 있는 기능이 있어야함. 수정/생성권한 포함 +- 회사관리자 : 고객으로 본인회사의 양식을 만들거나 개인 프로젝트에 사용할 개인 마스터데이터 관리 +- 회사관리자는 개인버튼을 누를경우 최상위 트리에는 다른 개인들의 정보를 보고 복사하여 개인에게 복사하거나 수정, 생성도 가능해야함. +- 개인은 마스터와 회사는 읽기만, 다른 개인의 정보는 볼수 없음. 트리는 현재를 유지해야함. + +## 6. 구조물 조합 항목 변경 + +- 최상위 구조물 조합에 구조물 도면, 일위대가, 면적산출로만 표현(기존의 재료산출의 사용자가 추가하는 재료들과 값은 엑셀을 사용하지 않고 일위대가의 담은로직 처럼 하위에 별도의 컨테이너로 추가되는 자재항목을 넣어야함. 이때 M01자재목록에서 하나씩 가져오되 수량은 면적산출의 알약으로 값을 싱크하거나 별도의 모달창에서 셀을 지정하게 구현) +- 재료산출 항목은 삭제 +- 선택입력 항목은 삭제(구조물 조합에서는 각 옵션의 상세 구조물에 따라 미리 옵션을 다 지정할 생각임으로 그럴필요 없음) +- 면적산출의 미리보기에서 아래 알약 그룹 행을 하이드/쇼 되게 반영(기본값은 하이드), 단, 면적산출 상세에서는 알약 행 보이기가 기본값으로 +- 면적산출 상세 화면의 우측 패널에서 현재의 일위대가 조합 목록을 검색하는 기능이 아니라 현재 구조물도의 일위대가 담은 로직과 추가될 사용자 지정 재료를 보여줄것, +- 일위대가 상세화면에서는 미리보기(보기만/저장안함) 컨테이너는 각 단가산출 로직에 입력이 필요한 정보들임. 구조물별로 하나씩만들어져야함으로 구조물의 일위대가 항목에 옵션값이 저장되야함. 그리고 이값에 대한 지정은 담은 로직 컨테이너의 항목을 누르면 모달창이 나오고 사용자가 입력 및 변경할수 있게함. (하위에 지정된 항목과 값을 작은 글씨로 표현되게 반영) +- 담은 로직에는 기준 단가를 기준으로 넣었을때 나온 값과 수량산출값을 넣었을때 값에 대한 노무비, 재료비, 경비, 합계를 같이 보여줌. 이때 수량산출은 면적산출의 특정 셀값이나 사용자가 수기로 입력한 값이 될수 있음. 이렇게 되면 미리보기 계산 컨테이너 필요 없음. +- 일위대가의 상세 화면의 순서는 담은로직 > 자재목록 > 다이어그램 순서임. +- 면적산출 산출 상세의 우측 패널에 보여져야하는 결과는 결국 일위대가의 담은 로직컨테이너와 자재목록임. +- 추가된 자재목록에도 기준에 대한 단가와, 면적산출 엑셀의 특정 셀과 연결되었을때 나온 단가가 같이 나와야함. + +## 9. 오른쪽 클릭 + +프로그램 B와 M영역의 페이지들에 마우스 오른쪽 클릭을 할 경우 브라우저 옵션 안보이게 할것.(우리 프로그램의 옵션은 허용, 공통양식 사용) + +## 사용자 답 (10-4 · 일위대가 · 원문 그대로) + +일위대가의 경우 +1. 구조물 조합을 복사한 경우 신규 키를 생성하여 만듬. (값과 로직은 일단 같이 복사) +2. 신규 구조물 조합을 생성하는 경우 미리보기 페이지에는 새로만들기와 복사생성하기를 선택하게 하고 복사 생성인 경우에는 해당 리스트를 보여주고 선택하게 보여주는 값은 개인인 경우에는 개인을, 회사관리자인경우 개인, 시스템관리자인 경우 마스터를 기본값으로 보여주고 탭을 눌러 다른 항목에서 가져올수 있는 항목들을 보여준다. +3. 다시 말해 최종적으로는 구조물 조합에 일위대가, 도면, 면적산출이 연되는게 맞으나 특정항목만 다른 값을 복사하여 가져올수 있어야함. 저장이 된 데이터라도 변경가능해야함. +4. 현재 매칭이 안된 데이터는 나중에 삭제할예정이나 마스터 데이터를 수동 생성을 위해서 필요한 데이터들임. 차라리 실무문서라는 항목을 마스터에 별도로 만들어서 나중에 한번에 지우게 할수 있게 반영. diff --git a/docs/raw/M02_구조물조합개편/설계_정함.md b/docs/raw/M02_구조물조합개편/설계_정함.md new file mode 100644 index 000000000..229b350fd --- /dev/null +++ b/docs/raw/M02_구조물조합개편/설계_정함.md @@ -0,0 +1,105 @@ +# M02 구조물 조합 개편 — 정함 (브레인 · 10-4) + +근거 = `사용자요구_1004.md` · 조사 보고 A~H(각 창 `tmp/조사_39-0X.md`). 사용자가 바꿀 수 있음. + +## 1. 자료 틀 (B · sub7) + +- 일위대가 = 구조물 열마다 `labor/<열id>.json` · 번호 `SU-######`(도번 · 면적번과 같은 뒷자리 · 층마다 셈) · 자리 = 시스템 `resources/master_template/labor/` · 회사 · 개인 `…/templates/labor/` · 새 프로젝트 씨앗도 KINDS 로 함께. +- 문서 꼴: + +```json +{ + "종류": "일위대가", "판": 1, "열": "item1", "일위대가번": "SU-000001", "단위": "개소", + "본뜬": { "층": "system", "주인": null, "키": "UA000008" }, + "담은로직": [ + { "로직": "GF000219", "메모": "", "입력값": { "형식": "메쌓기" }, "수량": null }, + { "로직": "GF000137", "메모": "", "입력값": {}, "수량": { "손": 3 } } + ], + "자재": [ { "코드": "MT000008", "이름": "치즐", "규격": "", "단위": "EA", "수량": null } ], + "계산값": { "줄": [], "자재": [], "기준합": {}, "반영합": {} } +} +``` + +- 이름 = 구조물 열 이름(따로 안 둠) · 단위 = 자기 칸 · 같은 로직 두 번 금지 · 빈 일위대가 저장 됨 · 이름 겹침 검사 없음 · 입력값 = 그 로직 입력 이름만(모르는 이름 버림 · 빈 값 = 자동값). +- 수량 = `null`(면적산출 알약 연결 따름 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」 · 칸 연결 정본 = 면적산출 문서 `연결` 한 곳) | `{손: 수}`(손 입력 · 손이 칸보다 앞섬). +- 금액 = 기준(결과단위 그대로 · 원/10㎡ 면 10㎡ 값 · 단위 같이 표기) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 「원/1,000본」 배 1000(`_PER` 고침) · 자재 기준 단가 = M01 `master_material.element` 기본값. +- 계산 자리 = 기준 ③ 서버(M01 엔진 · calc 길 · 화면은 최신 응답만) · 반영 곱 ① 짝(`M02_MasterTemplete_Labor_Calc.ts` ↔ `M02_Labor_Doc.py` · 두 파일 머리에 짝 · 거울 시험 · 면적 칸 값이 바뀌면 화면이 바로 곱함) · [저장] 때 서버가 `계산값` 을 다시 적음(브라우저 값 버림 · 목록 합계용) · 상세는 늘 새로 셈. +- 길 = 문서 `GET/PUT /api/m02/layers/{층}/templates/labor/{열}`(양식 길 · owner) · 계산 `POST /api/m02/layers/{층}/labor/{열}/calc` {doc?, 수량?: {키: 값}} → {줄[{로직, 분모, 배, 수량, 수량출처, 기준{노무비,재료비,경비,계}, 반영{…}, 까닭}], 자재[{코드, 기준단가, 수량, 수량출처, 반영, 까닭}], 기준합, 반영합} · 알약 `GET /api/m02/layers/{층}/labor/{열}/pills`(로직 물량 + 자재). +- 집계표 열 `일위대가` · `자재조합` 칸 지움(정본 = labor 파일) · SS 장부 줄 = `일위대가번` · `도번` · `면적번`. +- 옮기기(한 번 · `M02_Labor_Migrate.py`) = 층마다 열마다 labor 파일 · 열 `일위대가` 키 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · 수량 null · 자재 []) · 여러 열이 쓰던 조합(UP10-000001)은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 몇 번 돌려도 같음 · 시스템 층(git)은 바로 · storage 층은 압축 사본 뒤 브레인이 돌림. +- 실무문서 = 마스터 `resources/master_data/일위대가조합.json` 통째(37 줄 · 손대지 않음 · 옮긴 뒤 어느 열도 안 가리킴 · M01 일위대가 조합 화면도 그대로) · 회사 · 개인 옛 조합(UC · UP)은 파일 그대로 · 화면에서 뺌. +- 프로젝트 = 스냅샷 `일위대가.json` 은 labor 문서 + 담은 로직 줄 · `입력값.json`(모양 그대로) 첫 값 = labor 입력값 · `단가.json` 에 자재 MT 줄 · 계산은 반영 같은 함수. +- 은퇴(화면이 안 부른 뒤) = 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 · 미리 보기 길(preview) · 일위대가 조합 나무 길. +- 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서(32장 「로직 지우기 서버 검사」 줄 끝남). + +## 2. 좌측 패널 (A · sub5) + +- 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · 구조물 조합 안 차례 = 층 단추 + 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출. +- 하위마다 첫 줄 「전체 항목」(누르면 `onAll(kind)` → H 목록) · 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = `onOpen({kind: "labor", name: 열})`(C 상세). +- 이름 「일위대가」(하위 제목 · 상세 제목 · 대시보드 카드 글) · 해시 `open=일위대가` = 구조물 조합 펼침 + 일위대가 하위 펼침 + 전체 항목. +- 「실무문서」 = 구조물 조합 밖 별도 컨테이너(도면 양식 아래) · 마스터 층 · 시스템 관리자만 · 목록 + 상세 읽기만(새 파일 `M02_MasterTemplete_Practice.ts`) · [통째 지우기](물음 두 번 · 서버 = 시스템 관리자 · 쓰는 곳 0 확인 · 줄을 비움 · 버튼은 사용자가 누를 때만). + +## 3. 일위대가 상세 (C · sub2) + +- UI_Main 종류 `labor` 로 엶(양식 길 읽기 · [저장] · [다시 읽기] · 캐시 = 다른 양식과 같음) · 차례 = 머리 → 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램 · 미리 보기 칸 지움. +- 담은 로직 줄 누름 = 입력 모달(그 로직 입력 전부 · 빈 칸 자동값 · 수량 = 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」 = 면적산출 상세로) · [확인] = 줄 값만(캐시) · 저장은 [저장]. +- 줄 아래 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 빈 입력 「빈 값 n」 붉게. +- 금액 칸 넷(노무비 · 재료비 · 경비 · 합계) · 칸마다 두 줄(위 기준 작게 · 아래 반영) · 맨 아래 합계 줄. +- 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액 · [자재 추가](M01 자재 하나씩 · `M02_Material_Pick.ts`) · 줄 누름 = 수량 모달. +- 다이어그램 = 저장된 줄 값 + calc 답(수량 있으면 반영 · 없으면 기준 · 표기) · 알약 누름 = 입력 모달 · 자재목록 = 카드 하나 → 재료비 칸. +- 보기 갈래(고치기 없음) 부품을 냄 — D 오른쪽 패널 · H 카드가 씀. +- 입력 목록 · 값 받기 부품은 선택 입력에서 옮겨 다시 씀(`M02_Labor_Inputs.py` · `M02_Labor_Values.ts` · `M02_Labor_Values_Model.ts`) · 값 칸 규칙 한 벌(M01 넣을 값) · 로직 입력이 바뀌어 사라진 값은 알림. +- 37장 「미리 보기 값은 구조물 조합으로 안 넘김」 뒤집힘. + +## 4. 면적산출 (D · sub3) + +- 미리보기 카드 알약 줄 켬끔 단추 · 기본 꺼짐 · pref(계정) · 상세는 늘 보임(단추 없음). +- 상세 오른쪽 패널 = 그 열 일위대가의 담은 로직 + 자재목록(C 보기 갈래 · 기준 · 반영) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 지움. +- 패널 줄 누름 = 칸 지정 모달(`M02_MasterTemplete_AreaLink.ts` · [지금 고른 칸으로] · 주소 · [풀기]) → 면적 문서 `연결` · spreadsheet 손잡이 `setLink(키, 칸|null, 항목)`. +- 알약 = 로직 물량 알약 + 자재 알약(`자재|MT키` · 알약 모드 「자재」 다시 씀) · 선택 입력 알약 빠짐. +- 면적 금액 서버 계산은 1장 calc 하나(따로 안 만듦). + +## 5. 목록 · 고르기 · 카드 (H · sub1) + +- 목록 부품 하나 `M02_MasterTemplete_ItemList.ts`(M01 머리 · 쪽 · 표를 import · 옮기지 않음) — 「전체 항목」 · 복사 생성 · 항목 하나 복사가 같이 씀. +- M02 상세 제목 = M01 꼴(18px · 단추 오른쪽) 전부. +- 목록 칸: 도면 = 도번 · 이름 · 폴더 · 단위 · 고친 날 / 면적산출 = 면적번 · … / 일위대가 = 일위대가번 · 이름 · 폴더 · 단위 · 담은 로직 수 · 합계(`계산값` · 없으면 「—」) · 고친 날. +- 「새 항목」 → 상세 칸에 [새로 만들기] [복사 생성] · 복사 생성 = 같은 칸 탭 + 목록 → 지금 보는 층 · 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 일위대가 새 번호. +- 카드 = 도면 · 일위대가 · 면적산출 셋(그 차례) · 빈 카드 = [새로 만들기] [복사 생성] · 찬 카드 = 「복사해 가져오기」(창 · 탭 + 목록 → 확인 → 덮어씀 · 번호 · 열 그대로) · 일위대가 카드 몸 = C 보기 갈래. +- 탭 = 마스터 · 회사 · 개인(나) · 다른 사람(드롭다운 · 맡은 회사일 때) · 실무문서(일위대가 고를 때 · 시스템 관리자) · 기본 = 시스템 관리자 마스터 · 그 밖 개인(회사 없으면 마스터) · 탭 목록 = E `readableSources`. +- 항목 하나 복사 서버 = `copy_from` 에 from_layer · from_owner(B 가 만듦) · 원본이 실무문서 조합이면 조합 → 일위대가 문서(입력값 빈 값). + +## 6. 권한 (E · sub4) + +- 「회사 C 를 맡음」 = 시스템 관리자 · 또는 C 의 회사 관리자 → C 회사 층 · C 모든 개인 층 = 보기 · 만들기 · 고치기 · 지우기 · 복사 대상. +- 일반 = 마스터 · 회사 읽기만 · 남 개인 안 보임 · 나무 그대로 · 프로젝트 apply · sources 도 같은 규칙. +- 사람 나무 = 맡은 회사면 켬 · 회사 사람 전부(나 맨 위 · 빈 사람 「비어 있음」) · 남의 층 첫 쓰기 = 소속 확인 뒤 뼈대. +- 「〇〇로 복사…」 = 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 사람) + 대상 폴더 고르기 창(`M02_MasterTemplete_TreePanel_CopyTo.ts`). +- 남의 층 쓰기 = `system_audit_logs` 한 줄(`M02_OTHER_LAYER_WRITE`). +- 실무문서 = 일위대가 조합 길 전부 시스템 관리자만(서버가 막음). +- 새 길(labor 문서 · calc · pills · 층 넘는 복사)도 `_root` · `_layer_dir` 한 벌을 거침. + +## 7. 지우기 (G · sub6) + +- G1 자재 조합(UM) · G2 재료산출(`material/` · SM · 카드 · 시트 · 옆 판 · 길 · 시험) 먼저 · G3 선택 입력(카드 · `inputs/` · 자리 · 알약 길)은 C 의 입력 모달이 선 뒤. +- `pickMaterial` · `MaterialItem` → `M02_Material_Pick.ts` 로 옮긴 뒤 지움 · 알약 모드 「자재」 · `LinkMaterial`(A00 spreadsheet) 남김 · `고른자재.json` 남김 · 프로젝트 `입력값.json` 남김. +- storage 층(회사 · 개인 · 프로젝트)의 `material/` · `inputs/` · `자재조합.json` 파일은 지우지 않음(읽기만 끊음 · 정리는 사용자) · git 안 시스템 층 · fixture 는 지움. +- 옛 SM 번호 버림 · 장부 `재료번` 칸은 다음 집계표 [저장] 때 빠짐. + +## 8. 파일 주인 (같은 파일 = 몫만 고침 · 받은 뒤 고침) + +| 파일 | 주인 | 다른 창 몫 | +| --- | --- | --- | +| `M02_MasterTemplete_Store.py` · `M02_Template_Layers.py` | sub7 | sub6 = material · inputs 줄 | +| `M02_MasterTemplete_Router_Items.py` | sub4(`_root`) | sub7 = copy 길 from_layer · sub6 = material · inputs 길 | +| `M02_MasterTemplete_Router_Combos.py` · `Combo_Store.py` | sub5(나무 길) | sub4 = 시스템 관리자 문 · sub2 = preview 지움 | +| `M02_MasterTemplete_UI_Side.ts` | sub5 | sub6 = material · matset 줄 | +| `M02_MasterTemplete_UI_Page.ts` | sub5 | sub4 = copyTo · sub1 = onAll 이음 | +| `M02_MasterTemplete_UI_Main.ts` | sub3(면적 길) | sub2 = labor 종류 · sub1 = 제목 꼴 · showView · sub6 = material 길 | +| `M02_MasterTemplete_ItemCards.ts` | sub1(카드 셋 · 빈 카드) | sub3 = 알약 켬끔 · sub6 = 재료 · 선택 입력 카드 | +| `M02_MasterTemplete_Structure.ts` | sub3(pillsFor) | sub1 = pickCopy · sub6 = inputs · material | +| `M02_MasterTemplete_TreePanel.ts`(670줄) | sub1(copyIn · 넘으면 `TreePanel_Clip.ts`) | sub5 = 일위대가 전용 칸 지움 · sub4 = 「〇〇로 복사…」 이음 | +| `M02_MasterTemplete_Labor_*.ts` | sub2 | sub5 = `Labor.ts` · `Labor_Tree.ts` 나무 지움 | +| `M02_MasterTemplete_Api_Fetch.ts` | 각자 자기 함수만 | — | +| `A00_Common/spreadsheet/spreadsheet_links.ts` · `spreadsheet.ts` | sub3 | — | +| 공용 메뉴 · 진입(`main.ts` · `cad_host.ts` · `ui_template_context_menu.*` · `ui_template_tree.*` · `spreadsheet_condition.ts` · `spreadsheet_menu.ts`) | sub8 | — | diff --git a/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md new file mode 100644 index 000000000..fa33578c6 --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md @@ -0,0 +1,108 @@ +# 지난 계획 — 33~38장 (2026-10-01 ~ 10-02) + +단가산출 로직 점검(데스크탑 한시) · 쉬운 단가산출 · 로직 만들기 시험판(세 칸 판 · 시트 로직 · 다이어그램) · 흐름도 시험판 · 흐름도 본 프로그램 적용 · 일위대가 조합 다이어그램 · 마스터 요소 화면 손질. + +## 33. 단가산출 로직 점검 · 수정 (사용자 + 데스크탑 메인 · 보조 · 10-1 한시) + +- 사용자가 데스크탑 메인 · 보조와 직접 진행 · 브레인은 랩탑 sub1~8 로 사용자가 요청하는 다른 일을 맡음(10-1 18:00 무렵까지). +- 근거 = `docs/raw/M01_점검/종합/` · `docs/raw/M01_결정목록/` · `docs/raw/M01_입력목록/` · `docs/raw/M01_고침/`. + +## 34. 쉬운 단가산출 제어 (새 길 · 10-1 · 랩탑) + +- 사용자 확정(10-1 오전): 만든 쉬운 단가산출은 유지(지우지 않음) · 로직 정의 자체를 만드는 판은 제안만 · 허락 뒤 착수 +- 사용자 확정: 지금 단가산출은 그대로 · 「추가」 단추 옆 새 단추로 따로 들어감 · 코드는 `M01_MasterData/EasyCalc/` 한 곳(안 되면 폴더째 지움) · 초등학생도 추가 · 삭제 · 수정 · 시간이 걸려도 검토 먼저. +- 정함(브레인): 새 길은 지금 로직 형식을 그대로 읽고 씀(계산 엔진 안 바꿈) · 폴더 밖 참조는 단추 자리 한 곳만. +- [x] 34-1 검토 — 데이터 · 계산 지도(sub4) · 지금 화면 걸음 재기(sub6) · 실무 모양 + 갈래 비교(sub7) · 지난 시도(sub8) → `docs/raw/M01_쉬운단가/` · 지금 화면 새 로직 38 누름 · 5~6 창 +- [x] 34-2 설계 확정(브레인) — 문장 카드 · 품셈 기본은 「이걸 바탕으로 고치기」 · 쓰기는 `/logic/*` 만 · 1단계 숫자 수량 + 잠금 카드 → `docs/raw/M01_쉬운단가/설계.md` +- [x] 34-3a 단추 + 빈 창(sub1 · 「식으로 새로 만들기」 옆) 검증완료 +- [x] 34-3b 1단계 — Model(sub7) · Api(sub4) · Cards(sub2) · Pick(sub3) · Sheet(sub5) · 틀 · 잇기(sub1) — 설계.md 2 · 3절 · Sheet 검증완료 · Model 검증완료 · Api 검증완료 · Pick 검증완료 +- [x] 34-3c 2단계 — 표 보고 고르기(sub8) · 조건 고치기(sub7 · 조건 줄 93% 읽음) 검증완료 · 카드 단추 잇기(sub2) · 조건 고치기 검증완료 +- [x] 34-4 검증 — 대표 일 여섯 + 예외(빈 값 · 같은 이름 · 저장 안 하고 닫기 · 권한 없음 · 밝음 · 어두움) · 지우기 시험(폴더 밖 참조 줄 수) · 걸음(sub3 고침 뒤) ①21 ②8 ③2 ④4 ⑤2 ⑥3 = 40(목표 36 넘음 4 · ① 새 설계 값 자동값으로 채워 금액 곧장 보임) · 다른 일위대가 넣고 저장 됨 · 권한 없음(일반 사용자) = M01 화면 못 들어감 · /api/m01 403 · 폴더 밖 참조 두 줄 · 검증완료 + +## 35. 단가산출 로직 만들기 판 (제안 단계 · 착수 전) + +- 사용자 확정: 쉬운 단가산출처럼 따로 폴더 시험판 · 로직 몇 개 골라 만듦 · 페이지 보고 판단(마음에 안 들면 지움) · **착수는 허락 뒤** · 인터넷 조사 먼저 · 화면 용어는 단어로 다시 제안. +- 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY (세 칸: 원문 표 · 산출 과정 · 결과) +- [x] 35-0 인터넷 조사 — 견적 프로그램(sub6) · 노코드 규칙 도구(sub8) → `docs/raw/M01_쉬운단가/로직판_조사_*.md` · 제안서 2판에 반영 +- [x] 35-1 용어 단어 제안(제안서 2판 「용어 — 단어로」) → 사용자 확정 · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-2 시험판 — 사용자 허락(10-1 · 갈래가 여럿이면 갈래마다 · 목적 = 코딩 없이 원문 보며 확인 · 수정 · 생성) · A 세 칸 판 `LogicBuilder/` · B 엑셀 시트형(우리 스프레드시트) · 먼저 조사: 엑셀 로직 지도(sub4) · A 설계 초안(sub7) → 브레인 A 대 B 판단 → 구현 · 완료 처리(10-4): 35-2a 로 함 +- [x] 35-2a A 세 칸 판(가운데 문장 / 격자 두 보기) — 정함 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · Blocks(sub7) · 서버 · Api(sub4) · 원문 · 검사(sub8) · 문장(sub2) · 격자(sub5) · 단계 안내(sub3) · 틀 · 잇기 · 끝 검증(sub1) · LB 서버 검증완료 · Blocks 검증완료 · Grid 검증완료 · Steps 검증완료 · Source · Check 검증완료 · Wizard 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 원문 대조 6/6 블록 읽힘 · 걸린 줄 · 출처 보임 ② GF000219 표 칸 0.15→0.20 = 79,280→96,759 원(예상과 같음) · 파일 안 바뀜 · 다시 열면 0.15 ③ 단계 안내 11 걸음 + 저장 → 현장 다시 열림 60,984 원(/calc 같음) → 삭제 2 걸음 ④ 빈틈 해당 없음 10 · 못 따진 표 조회 1 · 기대값 시험표 맞음/다름 · 화면 초안만 ⑤ 표 고침 없을 때 lb/calc = /calc 8/8 · 문장 ↔ 격자 같은 값 · 예외(빈 이름 · 저장 안 하고 닫기 · Ctrl+Z · Ctrl+S · 권한 없음 403 · 밝음 · 어두움) · 폴더 밖 참조 LogicLab.ts 두 줄 + main.py 등록 한 줄 +- [x] 35-2b 남은 것(사용자 판단 뒤) — 문장 / 격자 고르기 · 표 칸 고침 저장 여부 · 적용 품셈 판 · 용어 · 칸 역할 「결과」 바꾸기 · 단계 안내 고르기 창 수량 물음 · GX000001(메인 11:53 · 12:02) 주인 확인 · 보고 https://claude.ai/artifact/NqKfHaJULcQo6dSvnsxy1n · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-3 점검(확인만) — 표끼리의 관계(로직) 자체를 초등학생이 만들고 · 바꾸고 · 지울 수 있나 · 품셈에 없는 관계 더하기 · 시나리오 여섯(sub6) — 결과: 있는 관계의 값만 고침(초등 중) · 관계 더하기 · 지우기 · 다시 잇기 · 새 입력값 · 새 표 · 로직끼리 바꾸기 안 됨 · 문턱만 바꾸면 계산 멈춤 + 엉뚱한 알림 · `docs/raw/M01_쉬운단가/로직판_관계제어_점검.md` +- [x] 35-4 다이어그램 제안(사용자 생각 10-1) — 상자 = 표 · 입력값 · 고정값 · 계산 · 항목 · 다른 로직 · 결과를 직접 다룸 · 선 = 관계를 만듦 · 품셈에 안 묶임(사용자 표 · 사용자 관계) · 관계 제어 = 다이어그램(사용자) · 위 가로 띠 + 아래 고른 상자 다루기 · 결과 · 관계 = 진짜 엑셀 수식(칸 주소 · 함수 · 사용자 확정) · 알약 = 입출력 칸 표시 · 새 종류 「시트 로직」(TS 엔진 한 벌 · 서버 Node) · 제안서 https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 허락 전 착수 금지 · 완료 처리(10-4): 35-5 · 35-7 로 함 +- [x] 35-5 시트 로직 시험판(사용자 허락 10-1 오후) — 위 가로 다이어그램 · 상자 = 시트 · 관계 = 엑셀 수식 · 입출력 = 알약 · 저장 = `M01_MasterData/SheetLogic/_data/`(git 밖 · 폴더째 지움) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` · Model · Eval(sub7) · 서버 · Api(sub4) · Diagram(sub8) · Panel(sub2) · Table(sub3) · Item · Result(sub5) · Import(sub6) · 틀 · 잇기 · 끝 검증(sub1) · SL 서버 검증완료 · Model 검증완료 · Diagram 검증완료 · Item · Result 검증완료 · Table 검증완료 · Import 검증완료 · 잇기 · 끝 검증완료(sub1) — 걸음: S1 ①②③ 각 3(79,280 · 73,085 손셈 같음 · 2.5m 문턱은 품셈 표 3m 부터라 2.5~3m 해당 없음) · S2 ① 3 ② 4(찰쌓기 표로) ③ 5 · S3 ① 약 19(경사 급 65,018) ② 9(내 표 붙여넣기 · 할증 10) ③ 7(잡재료 2% 17,551) · S4 선 끊기 2 · 상자 지우기 2(물음 한 번) · Ctrl+Z 되돌림 · S5 0(상자 · 선 · 값 한 장 · 고르면 이어진 선 진하게) · S6 항목 일위대가 11 · 다른 로직 상자 13(덤프트럭 16.2) · 새 로직(품셈 없이) 25 걸음 → 저장 서버 36,000 = 화면 → 다시 열림 → 지우기 2 · 서버 = 화면 4/4(36,000 · 17,550.9 · 10.12 · 다시 열기) · 가져오기 6/6 /calc 같음(0 · 60,984/79,280 · 2,064,816 · 670 · 16.2 · 57,516) · 예외(빈 이름 · 같은 상자 · 순환 · 빈 입력 물음 · 저장 안 하고 닫기 · 권한 없음 sl 5/5 403 · 밝음 · 어두움) · 세 칸 안쪽 세로 밀기 없음(6개 모든 상자) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 남은 흠(다른 창): 가져온 표 상자 + 결과 칸 목록 예시값이 글자로 들어가 30 등에서 해당 없음(sub5) · 가져온 로직 화면 번호 · 자재 검색어 · 끝 검증완료(S1~S6 · 새 로직 · 서버 = 화면 4/4 · 가져오기 6/6) · 남은 흠: 결과 칸 예시값 선택이 글자 「30」 으로 들어가 표 찾기 해당 없음(sub5 · 사용자 지시 뒤) +- [x] 35-5a 사용자 요청 — 다이어그램 띠 · 고른 상자 시트 · 결과 높이를 안쪽 세로 스크롤이 없을 때까지(sub1) +- [x] 35-6 로직 문법 먼저(사용자 방향 10-1 14시 무렵 · 화면보다 로직) — 바탕 표(인력 378 · 재료 30,767 · 기계 654 · 소요량 2,535 · 계수 498 · 환율 · 요율 · 사용자 표 — 보기 · 만들기 · 비목은 표가 정함) · 단위 넷(값 · 표 · 정보 · 요소) + 묶음(세분화 · 묶기 · 분류 · 묶음째 잇기 · 겹 묶음 · 틀 다시 쓰기) · 흐름 셋(정보 만들기 · 잇기[그대로 · 바꿔서 · 갈라서 · 모아서 · 묶음째] · 내기) · 확인 도구 · 제안서 https://claude.ai/artifact/BFWvm1Q4fhCSZ4Rt34UgYY · 다음 = 문법 확정 → 종이 연습(절 10개) — 허락 전 착수 금지 · 완료 처리(10-4): 35-7 · 35-9 흐름도로 갈음 +- [x] 35-5b 단추 공용 양식(점검 끝 · 고침은 사용자 지시 뒤) — 날 단추 23(목록 줄 8 · 칩 · 역할 · 보기 바꿈 11 · ▾ 2 …) · sm 단추를 44px 로 덮기 3 · 큰 시작 56px 3 · `docs/raw/M01_쉬운단가/시험판_단추_점검.md` · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-7 단가산출 다이어그램 시험판(사용자 기준 10-1 14시 무렵) — 입력 · 함수 · 출력 상자 · 자유 배치 · 함수 = 엑셀 · 출력 하나 · 출력 = 비용 종류 · 원가 · 폴더 `M02_MasterTemplete/CostFlow/`(_data · _sample) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` · Model · Eval(sub7) · 서버(sub4) · Diagram(sub8) · 함수 판(sub2) · 입력 판(sub3) · 출력 · 원가(sub5) · 견본(sub6) · 단추 · 페이지 · 잇기 · 끝 검증(sub1) · Model 검증완료 · Output · Summary 검증완료 · Sample 검증완료 · FuncPanel 검증완료 · Diagram 검증완료 · CF 서버 검증완료 · InputPanel 검증완료(사용자 요소 만들기는 서버 Api 가 main 에 오면 확인) · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 손으로 메쌓기 꼴(변수 4 · 소요량 표 2 · 인력 2 · 함수 증가율 → 석공 / 보통인부 병렬 · 출력 노무비 2) 9 상자 · 약 138 손놀림 → 원가 148,471 = 견본(증가율 40 · 석공 97,882.512 · 보통인부 50,587.992 손셈 같음) ② 비용 종류 바꾸면 148,471 이 노무비 → 경비 → 재료비 로 옮겨감 · 「값」 이면 원가에서 빠짐(각 1 걸음) ③ 상자 끌어 옮김(1520,190 → 1675,306) · 저장 → 다시 열면 그대로 ④ 함수 출력 하나(다른 칸 고르면 옮겨감 · 알약 하나) · 순환 막힘 · 출력에서 나가는 선 없음 · 빈 받는 자리는 저장 때 물음 ⑤ 사용자 요소(시험 보조공 200,000) 5 걸음 · 마스터 그대로 · _data 에만 ⑥ 저장 때 서버 = 화면 2/2(148,470.504) ⑦ 단위 오른쪽 위(- → 원/㎡) · 색 바꾸기(Entry 판 머리) · 「다이어그램」 이름 · 다이어그램 726px · 안쪽 스크롤은 함수 격자(가상 200줄)만 · 밝음 · 어두움 · 권한 없음(cf 길 6/6 403 · 화면 못 들어감) · 견본 셋 = /calc · 손셈(148,471 · 3,000 · 15.552) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 끝 검증완료(손으로 메쌓기 9 상자 = 견본 148,471 · 비용 종류 옮김 · 자리 저장 · 규칙 · 사용자 요소 · 서버 = 화면 · 견본 셋 손셈) · 함수 출력 기본 이름 겹침 고침(sub7 2be767d18) · 남은 것: M01 단가산출 단추 줄이 셋째 줄까지(시험판 단추 넷 · 사용자 결정) +- [x] 35-7a 브레인 실제 검증(10-1 16:45) 흠 여섯 고침 — ① 고칠 것 칸 지난 경고 · ⑥ 시험판 단추 「시험판 ▾」 로 묶기(sub1) · ② 알약 쉼표 · ③ 표 알약 글 · ⑤ 맞춤 확대 90% 아래 안 감(sub8) · ④ 입력 상자 공통 단위(sub3) · 다시 검증완료(16:55 · 고칠 것 칸 비어 있음 · 알약 쉼표 · 표 · 40줄 · 인력 원/일 · 확대 90% · 막대 한 줄 · 시험판 ▾ 넷 · 원가 148,471) + +## 35-8. 단가산출 다이어그램 2판 — 재료 선택 자리 · 선 규칙 (계획 · 허락 전) + +- 사용자 확정: 고르는 방식은 재료만(다른 그룹은 상자를 늘림) · 설계 때 입력은 오른쪽 아래 검증 칸 · 나중에 프로젝트에서 일위대가로 쓸 때 재료는 목록에서 고르거나 단일 값 · 표에 따라 출력을 가르는 것은 엑셀 수식 대신 선 규칙. +- 정함(브레인): 재료 요소 = 고정 · 선택 자리 · 조건 찾기 · 알약 이름 = 자리 이름 · 선 규칙 = 선 하나에 하나(표에서 찾기 · 조건 통과 · 바꾸기) · 규칙은 받는 칸 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임. +- [x] 35-8a 틀(Model) — 재료 요소 방식 · 범위(구분 · 상세구분 · 찾는 말) · 조건(규격 ← 알약) · 기본 품목 · 선 규칙 칸 · 규칙 → 받는 칸 수식 · 조건 선 겹침 · 안 맞음 검사 — node 시험(sub7) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8b 서버 — 선택 자리 기본 품목 · 조건 찾기 단가(맞음 0 · 여럿 알림) · 「설계 때 정할 것」 목록(변수 + 재료 자리) 내보내기 — 시험(sub4) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8c 입력 판 — 재료 방식 · 범위 · 조건 규칙 · 기본 품목 · 찾기 고침(쪽 넘기기 · 띄어쓰기 AND · 규격까지) — ORCA(sub3) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8d 검증 칸 — 재료 선택 자리를 변수처럼(범위 안 목록 고르기 · 찾기) · 조건 자리는 잡힌 품목 표시 — ORCA(sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8e 선 규칙 — 선 위 규칙 글 · 선 누르면 아래 판에서 규칙 고르기(표 찾기: 조건 칸 ↔ 알약 · 꺼낼 칸 / 조건 통과: 알약 · 같음 · 다름 · 이상 · 이하 · 값 / 바꾸기: × · ÷ · +% · 단위 · 끝수) · 고른 상자 판에 들어오는 선 규칙 목록 — ORCA(sub8 · 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8f 견본 · 끝 검증 — 견본 둘 더(지반에 따라 굴삭기 / 브레이커 가르기 · 흄관 관경 조건 찾기) · 사용자처럼: 함수 상자 없이 표 찾기 · 가르기 · 검증 칸에서 재료 고르기 · 방법 걸음 수(sub6 견본 · sub1 잇기 · 끝 검증) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8g 다이어그램 마우스 — 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자가 선택되지 않음(다이어그램 영역 글자 선택 막음) · 상자 옮기기는 상자 머리 왼쪽 끌기 그대로 — ORCA(sub8) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8h 「고른 상자」 → 「상세 내용」 · 상자뿐 아니라 알약 · 선을 골라도 내용이 바뀜(알약 = 값 · 단위 · 어디서 오나 · 어디로 가나 / 선 = 선 규칙 고르기) — ORCA(sub1 잇기 · 알약 · 선 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8i 상세 내용 단추 · 칸 크기 — 함수 판 꾸밈이 공용 작은 단추를 44px 로 덮는 줄(`M02_CF_FuncPanel.css` `.m01-cfpanel .ui-btn …`)을 지우고 판 안 단추를 모두 공용 작은 크기(createButton size "sm")로 · 입력 · 고르기 칸도 공용 크기 — ORCA 수치(sub2 · sub3 · sub5 각자 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8j 상세 내용 머리 — 왼쪽 「상세 내용」 · 오른쪽에 고른 상자 제목 · 알약을 골랐으면 그 오른쪽에 알약 이름(예 「높이 증가율 찾기 › 증가율」) · 상세 내용 안 칸 차례 정리(머리 · 상자 설정 · 본문 · 경고) — ORCA(sub1) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8k 단위는 고르기 — 단위 칸을 목록 고르기로(치면 걸러져 보이고 고르기는 사용자 · 손으로 쓴 글은 안 받음) · 목록 = 단위 표준(35-8l) 단위 + 마스터 결과단위 + 금액 단위(원) · 「-」 = 없음 · 함수 · 출력 · 변수 · 사용자 요소 단위 모두 같은 고르기 — 공용 고르기 파일 `M02_CF_UnitPick.ts`(sub2) · 판마다 바꿔 끼움(sub2 · sub3 · sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8l 단위 표준 표 — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` · 종목 약 70 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③)을 표로 — 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 흠은 사용자가 고침(시험판에서 보고 고치는 표 · 저장 `CostFlow/_data` · 처음 값 `CostFlow/_standard/`) — 시험(sub6) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8m 금액 단위 표준 — 같은 절 「금액의 단위표준」(설계서 총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 모두 미만 버림)을 같은 표 자리에 — 출력 상자(비용) 금액 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림 · 바꿀 수 있음(sub6 표 · sub5 출력 · 원가) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8n 끝수 고르기 — 함수 상자 출력 · 출력 상자에 「끝수」(소수자리 · 반올림 / 올림 / 버림) · 단위를 고르면 단위 표준에서 소수자리를 제안(주 ① 반올림 · ② 항목 값이 우선) · 고른 끝수는 출력 칸 수식(ROUND 계열)으로 — 엔진 한 벌(sub7 틀 · sub2 함수 판 · sub5 출력 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8o 로직끼리 잇기 · 흐름 따라가기 — 저장한 다른 로직을 입력 상자로 불러 그 출력 알약을 받음(일위대가 → 일위대가) · 알약 · 선을 고르면 값이 어디서 와서(출처 사슬) 어디로 가는지(가는 곳) 다이어그램에서 길을 칠하고 상세 내용에 줄로 · 값이 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건) 표시 — 시험 · ORCA(sub7 틀 · sub8 다이어그램 · sub1 상세 내용) · 완료 처리(10-4): 35-9 에서 함 +- 차례(사용자 10-1 · 「데이터의 흐름과 값의 연결에 집중」): **1단계 = 흐름 · 연결** a 틀 · b 서버 · e 선 규칙 · o 로직끼리 · 흐름 따라가기 · h 알약 · 선 고르기 · c · d 재료 자리 · f 견본 · 끝 검증(흐름 기준 — 값이 어디서 와서 어떤 규칙을 지나 어디로 가는지) / **2단계 = 값의 뜻 · 보기** k 단위 고르기 · l 단위 표준 · m 금액 표준 · n 끝수 · j 머리 · g 마우스 · i 단추 + +## 35-9. 단가산출 흐름도 시험판 (사용자 지시 10-1 17시 무렵 · 브레인이 끝까지 · 35-8 전체) + +- 사용자 확정: 브레인이 전체 제어해 새 시험판 하나 · 단가산출 다이어그램(시험)만 남기고 옛 시험판 셋 삭제 · 새 폴더(필요 없으면 바로 지움) · 데이터 흐름 · 값 연결 먼저 · 자유도 높고 이해하기 쉬운 화면 · 끝까지 구현 · 검증. +- 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` · 폴더 `M02_MasterTemplete/DataFlow/` +- [x] 35-9 창 여덟 — 옮기기 · Model · Eval(sub7) · 옛 시험판 지우기 · 들어가는 길 · 상세 내용 머리 · 잇기 · 끝 검증(sub1) · 서버(sub4) · 다이어그램(sub8) · 상세 내용 판 · 단위 고르기(sub2) · 입력 판(sub3) · 출력 · 검증 칸(sub5) · 표준 표 · 견본(sub6) · 표준 · 견본 검증완료 · Model 검증완료 · 출력 · 검증 칸 검증완료 · Diagram 검증완료 · 입력 판 검증완료(서버 자재 찾기 · UnitPick 이 main 에 오면 다시 확인) · DF 서버 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 함수 상자 없이 네 흐름 73 걸음: 재료 조건 찾기(관경 → 흄관 자리 · 13 개 중 하나 · 125,870) 21 · 선 규칙 표에서 찾기(높이 증가율표 → 출력) 19 · 조건 통과 두 갈래(지반 = / ≠ 토사) 27 · 다른 로직 잇기(견본 지반 · 지반 알약 이음) 6 ② 알약 · 선 고름 → 온 길 · 가는 길 칠함 + 상세 내용 줄(지반 → 다른 로직 · 두 조건 선 · 흄관 ← 관경) ③ 끊긴 곳 넷 보임(빈 받는 자리 · 통과 안 됨 · 표에 맞는 줄 없음 · 맞는 재료 없음 — 알약 · 선 「!」 · 끊긴 곳 줄 누르면 그 자리 고름) ④ 검증 칸 관경 600 → 800 = Φ800 품목 215,530 · 지반 토사 → 암 = 내 두 갈래 뒤집힘 + 다른 로직 2,000 → 10,000 · 다른 로직 설계 값 바꿈 10,000 → 20,000 ⑤ 단위 고르기 「mm」 = 3 개로 걸러짐 · 손글 안 받음 · 끝수 80,000.37 = 기본 225,870 / 1원 올림 225,871 · 표준 표 고침 저장(3 → 2) → 처음 값으로 되돌림 ⑥ 가운데 끌기 = 판 옮김(−60, −30) · 왼쪽 끌기 = 판 그대로 · 글자 선택 막힘 · 단추 18px 모두 sm · 칸 30px ⑦ 저장 → 다시 열기 = 상자 9 · 선 6 · 자리 · 규칙 글 그대로 · 서버 다시 셈 = 화면 187,910 · 견본 다섯 사본 저장 서버 = 화면(148,470.504 · 2,000 · 84,680 · 3,216.6 · 값 15.552) · 밝음 · 어두움 · 로그인 없음 df 길 6/6 401(관리자 아닌 계정 403 은 계정 없어 못 함) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 검증 중 고친 내 흠(047f3a279 · 28469de62): 재료 조건 찾기 · 선택 자리 값을 서버에 안 물음(흄관 견본 관경 600 → 400 = 211,700 → 116,600 · 1m 84,680 → 46,640 · 1000 = 235,120 · 선택 자리 Φ600 → Φ400 = 211,700 → 116,600) · 「상자 이름」 칸(입력 · 함수 · 같은 이름 · 빈 이름 막음 · 출력은 출력 판) · 머리 이름 · 단위 칸 작은 크기(30px) · 「새로 만들기」 단추 76px(칸 폭 710 아님) · 표준 표 모달 제목 한 번 · 남은 흠(sub2): 선 규칙 표에서 찾기 조건 칸에 범위 이름(「높이」)이 없어 범위 찾기를 화면에서 못 만듦(높이 5 → 줄 없음 · 4 만 맞음) · 브레인 실제 검증완료(흠 14 고침 · 흐름도 시험 81 · 견본 다섯 서버 = 화면 · 보고 JgfqRzLPYgqXG7vB2cV1Yj) · 검증 대기 + +## 36. 단가산출 흐름도 본 프로그램 적용 (사용자 지시 10-1 21:40 · 결정 반영 22시 · 착수 승인 22:20 · 밤새 진행 · 아침 보고) + +- 사용자 확정: 흐름도 = 최종 · 로직 목록에서 로직을 열면 지금 내용 그대로 + 오른쪽 흐름도 패널(같은 로직) · 「식으로 새로 만들기」 지우고 「신규 로직」(흐름도 새로 만들기) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽) · 시험판 단추 · 입구 지움 · DataFlow 폴더 유지. +- 근거(마스터 로직 57 파일 · 1,472 로직 · 입력 3,518 · 중간 식 3,168 · 호표 줄 5,265): 식 말 = 찾기(표 · 조건 · 칸) 8,152 · 만약 3,532 · 또는 185 · 최대/최소 173 · 올림/반올림/버림 141 · 로직() 41 → 흐름도 표에서 찾기 · 조건 · 함수 · 다른 로직 입력으로 옮길 수 있음. +- 사용자 결정 D1(그대로): 「품셈원문도 제대로 구현된게 아니기 때문에 내가 흐름도까지 만들어서 로직을 볼려는 거잖아. 그러니 당연히 둘다 같은 내용 … 원문과 대조하면서 검증할때 잘못된게 있으면 흐름도에서 수정할거야」 → 한 로직 · 두 보기: 왼쪽 지금 내용 = 보기 · 고치기는 흐름도(공용 로직 포함 · 시스템 관리자) · 흐름도로 저장한 로직은 흐름도가 정본 · 왼쪽은 흐름도에서 만든 같은 꼴 · 아직 저장 안 한 로직은 지금 내용에서 자동으로 그린 흐름도(값 대조로 같음). +- 사용자 결정 D2(그대로): 「흐름에 대한 로직은 costflow폴더에 있을테니 일단은 그냥 두고 쓸대없는 견본과 버튼만 삭제」 → CostFlow 폴더 그대로 · 시험판 단추 · 견본 목록 지움(흐름도는 DataFlow 폴더만으로 돌아감 · CostFlow 를 부르지 않음). +- 사용자 결정(함수 상자): 「함수박스를 기준으로 하나의 결과가 나가는게 맞는것같아. 여러개가 동시에 나가면 혼란만 가중」 → 함수 상자 하나 = 결과 알약 하나(그대로) · 식은 상자 겉에 넣지 않음(상자가 커짐) · 함수 상자 · 선을 고르면 상세 내용에 사칙연산 · 조건이 보이고 고치면 바로 따라감. +- 칸 연결 = M02 방식 그대로(칸을 고른 채 알약을 눌러 하나씩 연결 · 식은 칸 주소 · `spreadsheet_links.ts` 직접 확인) · 계산 엔진 · 이름 풀기 · 이름 규칙 없음 — 보기만 칸 주소를 연결된 알약 이름으로 바꿈. +- 사용자 결정(조건): 「조건문은 현재를 유지하는게 보기 편한것같고 대신 어떤값을 지정할지는 입력된 리스트에서 선택 … 조건 값을 내가 넣으면 오기 … 복수 조건문도 넣어주고」 · 「조건 만들기도 엑셀 기능 내부에서 존재해야해」 → 선 조건은 지금 웹앱 모양 · 조건 값은 고른 알약이 가질 수 있는 값 목록에서 고름(변수 고르기 · 표 칸 값 · 재료 규격 등 · 목록이 없는 수는 숫자 칸 + 범위 검사) · 조건 여러 줄(그리고 / 또는) · 함수 상자 엑셀 시트 안 「조건 만들기」(칸 고름 → 만약 · 아니면 만약 · 아니면 줄 → 그 칸에 IF 식 · 값은 같은 목록에서 고름). +- 안전: 화면 시험 저장은 마스터 사본 폴더에서만(서버가 환경 변수로 사본 폴더를 씀 · 실제 resources/master_data 는 안 바뀜 · git status 로 확인). +- 창: sub2 36-0(판 · 조건 만들기) · sub7 36-0b(조건 틀 · 값 목록) → 36-2 · sub6 36-1 → 36-7 · sub8 36-3 · sub4 36-4 서버 · 사본 폴더 · sub5 36-4 왼쪽 보기 · sub1 36-5 · 잇기 · 36-8 · sub3 36-6 +- 브레인 정함(조사 뒤 · 아침 보고에 알림): 옛 로직 「기계 · 직종 · 유가 자리」(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 입력값으로 요소를 고름 · 71 로직)는 새 모드 없이 이미 있는 「조건 찾기」(이름 · 키 같음 · 변수 알약)를 인력 · 기계 · 유가 요소에도 씀 · 대조 입력 = check_master.시험입력 · 로직 셈을 부르는 곳(Store.calc · text · master_combo.run · M02_Item_Prices.calc · /logic/auto)은 한 곳에서 흐름 갈래. +- [x] 36-0 함수 상자 안 보기 · 칸 연결 조정(36-2 보다 먼저) — ① 상세 내용 「식 보기」: 결과 칸 식을 칸 주소 → 연결된 알약 이름으로(예: 1m 재료비 = 흄관 단가 ÷ 2.5) + 값 들어간 식(= 211,700 ÷ 2.5 = 84,680) · IF 는 만약 / 아니면 줄 · 지금 타는 갈래 표시 · 중간 칸 식도 차례대로 · 칸 식을 고치면 바로 다시 셈 → 다이어그램 · 원가 따라감 ② 「칸 연결 표」(칸 · 연결 알약 · 입력 / 결과 · 이어진 선 · 표에서 바로 바꿈 · 풀기) + 칸 색(입력 파랑 · 결과 초록 · 상자 색과 같음) · 결과 칸 하나 ③ 선을 고르면 그 선 규칙도 같은 「식 보기」 꼴(예: 굴착량 → 지반 = 토사 일 때만 → 굴삭기로 파기) ④ 엑셀 시트 안 「조건 만들기」 — 칸을 고르고 열면 만약 [알약] [비교] [값 고르기] → [알약 · 값] 줄 · 아니면 만약 더하기 · 아니면 → 그 칸에 IF 식(엑셀로 쓴 IF 도 같은 줄로 다시 열림 · 못 바꾸는 식은 식 그대로) · 검증완료(sub2 · 식 보기 · 칸 연결 표 · 조건 만들기 · 선 조건 여러 줄 · 시험 8) · 검증 대기 +- [x] 36-0b 선 조건 손질 — 조건 값 = 고른 알약의 값 목록에서 고름(Model 값 목록 함수 · 변수 고르기 · 표 칸 값 · 재료 규격 · 목록 없는 수는 숫자 칸) · 조건 여러 줄(그리고 / 또는) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 위 이름표 · 겹침 · 안 맞음 검사 그대로 · 검증완료(sub7 · 조건 여러 줄 그리고/또는 · 값 목록 · 서버 = 화면 · 시험 30) · 검증 대기 +- [x] 36-1 조사 — 옛 식 문법 전수(서버 파서 · 요소 부르기 꼴 · 따옴표 글) · 변환 못 하는 꼴 목록 · /calc 를 부르는 곳 · 검증완료 +- [x] 36-2 변환 — 옛 로직(입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수) → 흐름도(입력 상자 · 표 알약 + 표에서 찾기 · 함수 · 출력 상자) + 자동 배치 · 서버 파서로 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(만약 → IF · 최대 → MAX · 찾기 → 표에서 찾기) · 함수 상자 하나 = 결과 하나 · 전수 대조(변환 흐름도 값 = 옛 계산 값 · 맞는 비율 · 안 맞는 까닭) · 검증완료(sub7 · M02_DF_Convert · 전수 1,472 시험입력 96.7% · 기본 입력 97.4% · 안 맞는 49 = 재료 검색어 자리 18 · 재료 빈 검색어 13 · 표 범위 규칙 경계 15 · 단가 빈 품목 3 · tmp/convert_report_36.json) · 검증 대기 · 마지막 대조: 시험입력 · 기본 입력 모두 1,472/1,472(100%) · 빈 단가 = 옛 셈 뜻(그 값 쓰는 식 빈 값 · 비목 합 0 · 끊긴 곳 「단가 없는 품목을 씀」) +- [x] 36-3 오른쪽 패널 — 로직 상세 오른쪽에 흐름도(다이어그램 · 상세 내용 · 원가 · 검증) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면) · 검증완료 +- [x] 36-4 저장 · 정본 — 로직 줄에 「흐름」 칸(화면 [저장] 때만 씀) · 흐름이 있으면 /calc 가 흐름도로 셈(서버 다시 셈) · 왼쪽 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산은 흐름도에서 만든 같은 꼴(보기) · 공용 로직도 흐름도로 고침(시스템 관리자) · 캐시 → [저장] 한 번 · [고친 것 버리기] 함께 · 왼쪽 보기 검증완료(sub5) · 서버 검증완료(sub4) +- [x] 36-5 단추 — 「식으로 새로 만들기」 지움 → 「신규 로직」(빈 흐름도 + 기본정보 · 저장 = 자체 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(흐름도도 복사 · 신규 로직 오른쪽) · 「시험판 ▾」 지움 · 잇기 검증완료(sub1 · accf8ecc4 · abbf61971 · 0d45014e3) — 「식으로 새로 만들기」 · 시험판 ▾ · 시험판 단추 둘 지움 · 단추 차례 ← 로직 목록 · 신규 로직 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 · 두 칸(LogicLab_Flow) · 오른쪽 패널(흐름 받기 · 고침 → drafts → [저장] 한 번 · 버리기 함께 · 흐름 로직이면 왼쪽 흐름 보기) · 신규 로직 기본정보 칸(LogicLab_Basic) → 저장 = 서버 added 키로 엶 +- [x] 36-6 다른 로직 입력 — 대상 = 마스터 로직 전체(찾기) · 흐름도 로직이면 흐름도 셈 · 아니면 옛 셈 · 목록 · 찾기 · 쪽 · 알약 이음 검증완료(서버 흐름 받기 · 값 셈 길이 main 에 오면 GF 로직 값 확인) +- [x] 36-7 정리 — 「시험판 ▾」 · 시험판 입구 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 resources/tester 로 · 시험 저장 길 숨김 · DataFlow · CostFlow 폴더 그대로 · 검증완료 +- [x] 36-8 끝 검증 — 흐름 기준 · 옛 로직 열기 → 오른쪽 흐름도 값 = 왼쪽 계 · 신규 로직 → 저장 → 다시 열기 · 복사 · 권한 · 브레인 실제 검증 · 보고 · 끝 검증완료(sub1 · 사본 폴더 tmp/master_copy_36 + AISLO_MASTER_DIR · 네 포트) — ① 옛 로직 셋: GF000001(찾기) 오른쪽 34,413 = /calc 34,413.6 = 왼쪽 34,414(보임 끝수만 다름) · 변환 알림 한 줄 / GF000007(만약) 0 ≠ 597,285 · GF000288(로직()) 0 ≠ 1,234,760.96 — 아래 흠 ② GF000001 함수 식 ×1.5 → 오른쪽 · 왼쪽 51,620 따라감 → [저장](공용 GF · 사본) → 다시 열기 상자 6 · 변환 알림 없음 · /calc flow 51,620.4 = 화면 ③ 신규 로직 65 걸음(변수 셋 · 함수 · 조건 만들기 · 출력 둘 · 조건 선) → 저장 GX000004 → 다시 열기 상자 4 · 선 4 · 화면 100,000 = /calc 100,000 = 왼쪽 계(높이 2 = 0 · 지반 암 = 50,000) ④ 로직 복사/생성 2 걸음 → GX000005 상자 4 · 선 4 · 100,000 ⑤ 선 조건 두 줄 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 값 고르기(토사|암) · 높이 2 → 0 · 또는 → 50,000 · 조건 만들기 =IF(A2>=3,A3,0) ⑥ 식 보기 「결과 1 = 금액 × 높이 ÷ 4」 · IF 만약 / 아니면 줄 · 칸 연결 표 ⑦ 크게 보기 1484×1076 · 거기서 고친 것 패널로 · 접기 40px · 펴기 · 너비 320 → 522 ⑧ 고친 것 버리기 5 → 4 · 캐시 비움 · 로그인 없음 flow · save · calc · logics 401 · 시험판 · 옛 단추 글 0 · resources/master_data 바뀜 0 · 흠(다른 창): 변환 숫자 입력 예시값이 비어 있음(시험입력 · 기본 입력을 안 넣음 — GF000288 나무높이 6 · 집재거리 100 · GF000007 정리량 50 …) · GF000007 「표 QF000033 → 할증」 표에 맞는 줄 없음(서버 477,828 = 할증 25% 빠짐 · 화면 0) · GF000288 다른 로직 GC000270 기계 자리 「EQ:{기계} … 자리」 못 풂(#N/A → 0) · 칸 #N/A 일 때 화면 셈 0 · 서버 셈 그대로(둘이 다름)(sub7) · 식 보기 값 줄이 선 규칙을 지난 값을 안 씀(「(#N/A × 0) = #VALUE!」 · 결과 알약은 34,413.6)(sub2) · 조건 통과 둘째 줄 값 목록 「number:0」 글 · 조건 만들기 만약 목록에 놓인 알약 없음(sub2) · 옛 로직 왼쪽 계 반올림 · 흐름 원가 계금 1원 버림(1원 차이) +- 36 마무리(10-2 02시 · 브레인 실제 검증완료): 전수 변환 대조 시험입력 1,469/1,472(99.8%) · 기본 1,466(99.6%) · 남은 3 = 마스터 빈 단가 · 시험 141 + 변환 시험 초록 · 사본 서버 저장 → 사본만 바뀜 · 다시 열기 흐름 정본 · GF000001 34,414 = 34,414 · GF000003 8.946 = 8.946 · 식 보기 값 줄 · 검증 대기 + +## 37. 일위대가 조합 다이어그램 (사용자 지시 10-2 09시 · 계획 · 착수) + +- 사용자 확정: 「복잡한 경우가 있으니 일위대가 조합도 다이어그램으로 표현 … 중요하게 생각하는 정보가 다르니 잘 구분」 · 조합은 계산하지 않고 로직을 뭉쳐 비용 구분별로 표기 · 입력은 로직별로 묶어 빈 값은 사용자가 넣음 · 출력은 로직마다 · 미리 보기 값은 구조물 조합으로 안 넘김 · 「흐름도는 별도 로직이 아님」(단가산출 로직을 쓰는 방법). +- 모양: 왼쪽 = 로직별 입력 묶음(받아야 할 값 · 빈 값 표시) → 가운데 = 로직 카드(키 · 이름 · 결과 단위 · 식 · 함수 안 보임) → 오른쪽 = 비용 구분 칸 셋(노무비 · 재료비 · 경비 · 로직마다 선으로 모임) + 값 로직은 「값」 칸 · 이름이 같은 입력(지역 · 자재지역 등)은 점선으로 이어 보이기만(검사 없음) · 로직 카드를 누르면 그 로직의 단가산출 로직 화면(흐름도)으로 · 계산 · 고치기 없음(묶음 고치기는 지금 표) · 흐름도와 모양 · 색을 달리함. +- 자리: 일위대가 조합 상세(M02_MasterTemplete_Labor_Edit)의 미리 보기와 같은 값 — 미리 보기 입력 칸에 넣은 값이 다이어그램에도 · 다이어그램 빈 입력 알약을 누르면 미리 보기 그 칸으로 · 미리 보기 결과(로직별 노무비 · 재료비 · 경비 · 계)가 카드 · 칸에. +- [x] 37-1 다이어그램 판 — M02_MasterTemplete_Labor_Diagram.ts(+css) 새 파일 · 고정 세 칸 배치(자동) · 판 옮기기 · 확대 · 한눈에 · 밝음 · 어두움 · 좁은 폭 +- [x] 37-2 잇기 — Labor_Edit 에 「다이어그램」 칸(접기) · 미리 보기와 값 한 벌 · 빈 입력 누름 → 미리 보기 칸 · 로직 카드 누름 → M01 단가산출 로직 그 로직 열기(setPendingLogicKey 꼴) +- [x] 37-3 검증 — 조합 셋(로직 2 · 9 · 14개)으로 입력 묶음 수 = 로직 입력 수 · 출력 = 미리 보기 줄 · 비용 칸 합 = 미리 보기 합 · 빈 값 표시 · 같은 이름 점선 · 로직 열기 · 브레인 실제 검증 · 보고 +- 창: sub2 37-1 · 37-2 · 37-3 + +## 38. 마스터 요소 화면 손질 (사용자 지시 10-2 09:40 · 착수) + +- 사용자 확정: ① 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 상세의 제목 · 단추 · 찾기 라벨 양식 = 단가산출 로직과 같게 ② 재료 컨테이너의 유가 · 전력을 별도 컨테이너로 ③ 단가산출 로직 「막힘만」 체크 — 의미 없으면 지움 ④ 단가산출 로직 컨테이너 「전체」 를 누르면 상세 목록 보기가 안 됨(다른 컨테이너는 됨). +- 브레인 정함: 「막힘만」 = 로직 검사에 걸린 로직만 거르기 · 지금 1,469 중 0 · 목록 줄에 「⛔ 막힘」 표시 · 까닭 말풍선은 이미 있음 → 체크만 지움(표시는 둠). +- [x] 38-1 양식 맞춤 — 일곱 상세 화면 머리(제목 · 단추 줄 · 찾기 칸 라벨)를 단가산출 로직 화면 꼴로 · 공용 단추 · 칸 크기(sub5) · 검증완료 +- [x] 38-2 유가 · 전력 컨테이너 — 왼쪽 패널에 재료 다음 「유가 · 전력」 컨테이너(재료_유가전력.json) · 재료 컨테이너에서는 뺌 · 고르기 · 찾기 · 상세 그대로(sub6) · 검증완료 +- [x] 38-3 「막힘만」 체크 지움 · 38-4 「전체」 목록 보기 고침(sub1) · 검증완료 — 원인: 로직 컨테이너 거름이 목록만 거르고 가운데 화면 전환(onLogic)을 안 부름 → Side 거름 run 에서 로직이면 onLogic · 「전체」 는 찾는 글도 비움 · 화면(5174): 로직 1,469 줄 → 인력 전체 → 로직 「전체」 = 로직 화면 1,469 줄(찾는 글 「굴착」 비움) → 산림품셈 330 → 03장 작업장 관리 10 → 로직 열기 → 「전체」 = 상세 닫힘 · 1,469 줄 · 막힘만 체크 0 · 화면 오류 0 +- 「고친 것 버리기」(사용자 물음 · 확인만): 저장 안 한 고친 것(화면 캐시)을 버리고 파일의 로직을 다시 엶 — 사본 저장은 없음 · 그대로 둠(사용자 결정 기다림) From 1871b6ffc2a06985f72c07081b8d1a4f507d5c73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:20:47 +0900 Subject: [PATCH 03/36] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B4=EB=93=9C=20[=EC=95=8C=EC=95=BD]=20?= =?UTF-8?q?=EC=BC=AC=EB=81=94=20=E2=80=94=20=EC=95=84=EB=9E=98=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EC=A4=84=20=EA=B8=B0=EB=B3=B8=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=EC=A0=95=20=EC=B7=A8=ED=96=A5=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EC=83=81=EC=84=B8=EB=8A=94=20=EB=8A=98=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=84=20(PLAN=2039-4=20D1)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/b_page_state.ts | 2 ++ .../M02_MasterTemplete_ItemCards.ts | 18 ++++++++++++++++++ .../M02_MasterTemplete_Structure.css | 5 +++++ resources/tester/test_m02_card_sheet_edit.py | 14 ++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cca13de9d..9f9e3ae48 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -62,6 +62,8 @@ export const STATE_REGISTRY = { /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, + /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ + "m02-card-pills": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index fa329afec..3b3a24b3f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -13,6 +13,7 @@ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { LinkOptions, SpreadsheetHandle, @@ -62,6 +63,8 @@ const TEXT = { readFailed: "{value} 파일을 못 읽음 — {why}", discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", sheetHelp: "단축키 — 칸을 고르고 바로 침", + pills: "알약", + pillsTip: "아래 알약 줄 보임 · 숨김", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -221,6 +224,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return card; }; const sheets = [sheetCard("area"), sheetCard("material")]; + // 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임) + const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" }); + pills.title = TEXT.pillsTip; + const paintPills = (shown: boolean): void => { + pills.setAttribute("aria-pressed", String(shown)); + sheets[0].host.classList.toggle("is-pills-off", !shown); + }; + pills.addEventListener("click", () => { + const shown = pills.getAttribute("aria-pressed") !== "true"; + writeStateRaw("m02-card-pills", String(shown)); + paintPills(shown); + }); + paintPills(readStateRaw("m02-card-pills") === "true"); + sheets[0].shell.tools.prepend(pills); const cards: Card[] = [info, ...sheets]; const defaults: Record = { structure: TEXT.edit, @@ -244,6 +261,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.saver.button.hidden = c.help.hidden = !can; c.edit.hidden = !opts.editable(); } + pills.hidden = !sheetEdit; }; const drop = (): void => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index b44d120e2..57eaacbce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -247,3 +247,8 @@ .m02-struct__sheet-grid .ss-links__list { max-height: 8rem; } + +/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */ +.m02-struct__sheet.is-pills-off .ss-links { + display: none; +} diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index a4b3e62c6..6dbe46cf5 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -75,3 +75,17 @@ def test_저장_안_한_카드_고침_물음(): assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main + + +def test_면적산출_카드_알약_줄_켬끔_기본_숨김(): + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김 + assert 'writeStateRaw("m02-card-pills", String(shown));' in cards + assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards + assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음) + css = _text(M02 / "M02_MasterTemplete_Structure.css") + assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css) + state = _text(ROOT / "A00_Common" / "b_page_state.ts") + assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state + # 상세(전체 편집기)는 켬끔 없음 — 늘 보임 + assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts") From eee8f9a459b6ebaed5001c2e2a7b3302579e2c1c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:21:17 +0900 Subject: [PATCH 04/36] =?UTF-8?q?feat(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=ED=83=AD=20readableSources=20=C2=B7=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EB=A7=A1=EC=9D=8C=20manages=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1=20=C2=B7=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=ED=95=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=ED=83=AD=20=EB=AA=A9=EB=A1=9D(39-6=20E5)?= 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_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Layer.ts | 72 +++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 49 +++++++++++++ ui_template/ui_template_locale_m2.ts | 2 + 3 files changed, 123 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 742f8f20a..3b94c655c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -93,6 +93,78 @@ export function accessFor( return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; } +/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ +export function manages(company: string | null, user: SessionUser | null): boolean { + if (!company || !user) return false; + if (user.role === "SYSTEM_ADMIN") return true; + const companyAdmin = user.role === "ADMIN" || !!user.is_master; + return companyAdmin && company === ownOwner("company", user); +} + +/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */ +export interface Source { + id: "master" | "company" | "personal" | "people" | "practice"; + layer: ManageLayer; + /** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */ + owner: string | null; + label: string; +} + +/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭. + * 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */ +export function readableSources( + user: SessionUser | null, + company: string | null, + opt: { practice?: boolean } = {}, +): { tabs: Source[]; first: Source["id"] } { + const isSystem = user?.role === "SYSTEM_ADMIN"; + const own = ownOwner("personal", user); + const tabs: Source[] = [ + { id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") }, + ...(company + ? [ + { + id: "company" as const, + layer: "company" as const, + owner: company === ownOwner("company", user) ? null : company, + label: L("M02_PickerCompany"), + }, + ] + : []), + ...(own + ? [ + { + id: "personal" as const, + layer: "personal" as const, + owner: null, + label: L("M02_PickerPersonal"), + }, + ] + : []), + ...(manages(company, user) + ? [ + { + id: "people" as const, + layer: "personal" as const, + owner: null, + label: L("M02_SourcePeople"), + }, + ] + : []), + ...(opt.practice && isSystem + ? [ + { + id: "practice" as const, + layer: "system" as const, + owner: null, + label: L("M02_SourcePractice"), + }, + ] + : []), + ]; + return { tabs, first: isSystem || !own ? "master" : "personal" }; +} + /** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */ export function buildNeedCompany(): HTMLElement { const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" }); diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c7bb3f17f..f731a5644 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -87,6 +87,55 @@ def test_주소_인자를_라우트에서_뗌(): assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_가져올_곳_탭(): + """39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 · + 일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터).""" + text = _text(LAYER) + parts = [ + re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M), + re.search(r"^export function manages[\s\S]*?^}", text, re.M), + re.search(r"^export interface Source[\s\S]*?^}", text, re.M), + re.search(r"^export function readableSources[\s\S]*?^}", text, re.M), + ] + assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음" + script = ( + 'type ManageLayer = "system" | "company" | "personal";\n' + "interface SessionUser { id: string; role?: string; company_id?: number | null;" + " is_master?: boolean }\n" + "const L = (key: string): string => key;\n" + + "\n".join(p.group(0).replace("export ", "") for p in parts if p) + + "\nconst cases = JSON.parse(process.argv[1]);" + + "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {" + + "\n const got = readableSources(u, c, { practice });" + + "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];" + + "\n})));" + ) + sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1} + admin = {"id": "5", "role": "ADMIN", "company_id": 3} + master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True} + user = {"id": "7", "role": "USER", "company_id": 3} + orphan = {"id": "8", "role": "USER", "company_id": None} + cases = [ + [sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False], + [user, "3", True], [orphan, None, False], + ] # fmt: skip + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(cases)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄 + ["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님 + ["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음 + ["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자 + ["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음 + ["master", ["master"]], # 회사 없음 + ] # fmt: skip + + @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_볼_회사는_시스템_관리자만_고름(): """29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 · diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1f89b5c20 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -103,6 +103,8 @@ export const ui_locales_m2 = { M02_RootShared: ["회사 공용", "Company shared"], M02_RootReadOnly: ["읽기", "read-only"], M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], + M02_SourcePeople: ["다른 사람", "Others"], + M02_SourcePractice: ["실무문서", "Practice documents"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", From ab84c763b79f65f093efcc684ab445cb5adb0b36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:22:24 +0900 Subject: [PATCH 05/36] =?UTF-8?q?refactor(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=E3=80=8D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=C2=B7=20=EC=A1=B0=ED=95=A9=20=EB=82=98=EB=AC=B4=20=C2=B7=20?= =?UTF-8?q?=EB=91=98=EC=A7=B8=20=EC=B8=B5=20=EB=8B=A8=EC=B6=94=20=EB=B2=8C?= =?UTF-8?q?=20=EC=A7=80=EC=9B=80(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor.ts · Labor_Tree.ts 지움 · 페이지 · 좌측 이음 · 해시 표시(mark) 지움 - 나무 부품 일위대가 전용 칸(clipKind · maxDepth · item) 지움 - 옛 나무 시험을 새 짜임 검사로 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Labor.ts | 222 ------------------ .../M02_MasterTemplete_Labor_Tree.ts | 45 ---- .../M02_MasterTemplete_TreePanel.ts | 23 +- .../M02_MasterTemplete_UI_Page.ts | 36 +-- .../M02_MasterTemplete_UI_Side.ts | 24 +- .../M02_MasterTemplete_UI_Style.css | 8 - resources/tester/test_m02_labor_tree.py | 131 ++--------- resources/tester/test_m02_layer_screen.py | 13 +- 8 files changed, 39 insertions(+), 463 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts deleted file mode 100644 index 985d1a1f8..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor.ts - * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추 - * (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` · - * 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`). - * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). - * ========================================================================== */ - -import { el, showConfirmDialog } from "@ui/ui_template_elements"; -import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; -import { - editComboTree, - editComboTreeMany, - fetchComboList, - pasteCombos, - type ComboList, - type ComboTreeEdit, - type LaborScope, -} from "./M02_MasterTemplete_Labor_Api"; -import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit"; -import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; - -/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */ -function treeError(error: unknown): Error { - if (!(error instanceof ApiError)) - return error instanceof Error ? error : new Error(String(error)); - const d = error.detail as { errors?: string[]; stale?: unknown } | string | null; - const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`; - return error.status === 409 ? new StaleError(text) : new Error(text); -} - -export interface LaborLibraryOptions { - scope: LaborScope; - canEdit: boolean; - /** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */ - copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[]; - /** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */ - reveal: () => Promise; -} - -export interface LaborLibrary { - /** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */ - root: HTMLElement; - /** 좌측 패널 나무 */ - tree: HTMLElement; - /** 나무 맨 위 더하기 「+」(층 단추 옆) */ - add: HTMLElement; - /** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */ - back: () => Promise; - refresh: () => Promise; - /** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */ - tools: HTMLElement | null; - /** 메인 칸 「저장 안 한 고침」 손잡이 */ - bind: (setDirty: (dirty: boolean) => void) => void; -} - -export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { - const { scope } = opt; - let setDirty: (dirty: boolean) => void = () => {}; - let keys = new Set(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나 - const took = (list: ComboList): ComboList => { - keys = new Set(list.줄.map((c) => c.키)); - editor.setList(list.줄); - return list; - }; - const editor = createLaborEditor({ - scope, - canEdit: opt.canEdit, - onDirty: (dirty) => setDirty(dirty), - onSaved: async (key) => { - await tree.refresh(); - tree.select(key); - }, - }); - const tree = createTreePanel({ - layer: scope.layer, - owner: scope.owner, - canEdit: opt.canEdit, - maxDepth: 2, - item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) }, - api: { - fetch: async () => { - return toView(took(await fetchComboList(scope))); - }, - edit: async (_layer, edit, 판) => { - const body = { ...edit, 판 } as ComboTreeEdit; - const got = await editComboTree(body, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), noteOf(edit, got.지운조합)); - }, - // 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장 - many: async (_layer, body, 판) => { - const ops = body.대상.map((ref) => ({ - op: body.op, - ...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }), - ...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}), - })); - const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), { op: body.op, 지운항목: got.지운조합 }); - }, - // 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로 - paste: async (layer, owner, clip, dest) => { - const to = { layer, owner } as LaborScope; - const pick = { - 키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])), - 폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])), - }; - const got = await fetchComboList(to) - .then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)) - .catch((error: unknown) => { - throw treeError(error); - }); - const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null); - return toView(took(here ? got : await fetchComboList(scope))); - }, - }, - clipKind: "일위대가", - copyTo: opt.copyTo, - onPick: async (item) => { - await opt.reveal(); - tree.select(item.key); - void editor.open(item.key); - }, - // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) - settle: async () => { - if (!editor.isDirty()) return "clean"; - if (!(await showConfirmDialog(tc("BackAsk")))) return false; - editor.showList(); - return "changed"; - }, - // 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록 - onChanged: async () => { - const open = editor.current(); - if (!open) return; - if (keys.has(open)) await editor.open(open); - else editor.showList(); - }, - }); - // 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름 - tree.search - .querySelector("input") - ?.addEventListener("input", (event) => - editor.setQuery((event.target as HTMLInputElement).value), - ); - return { - root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }), - tree: tree.root, - add: tree.add, - back: async () => { - await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움 - if (editor.current() === null) return; - if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return; - editor.showList(); - }, - refresh: async () => { - const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6) - try { - await tree.refresh(); - } finally { - stop(); - } - }, - tools: editor.newButton, - bind: (fn) => (setDirty = fn), - }; -} - -export interface LaborSectionOptions { - /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ - show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void; - /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ - confirmLeave: () => Promise; - /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ - onOpen: () => void; - /** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */ - make: (reveal: () => Promise) => LaborLibrary; - /** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */ - bar?: (add: HTMLElement, back: () => void) => HTMLElement; - /** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */ - wrap?: (tree: HTMLElement) => HTMLElement; -} - -export interface LaborSection { - title: string; - body: HTMLElement; - open: () => Promise; - /** 다른 화면을 열었음 — 고름 표시만 지움 */ - clear: () => void; -} - -/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */ -export function buildLaborSection(opt: LaborSectionOptions): LaborSection { - let opened = false; - const mark = (on: boolean): void => { - opened = on; - }; - - async function open(): Promise { - if (opened || !(await opt.confirmLeave())) return; - library.bind(opt.show(tc("Library"), library.root, library.tools)); - opt.onOpen(); - mark(true); - await library.refresh(); - } - // 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음 - const library = opt.make(open); - void library.refresh(); - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - children: [ - ...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []), - opt.wrap ? opt.wrap(library.tree) : library.tree, - ], - }); - return { title: tc("Title"), body, open, clear: () => mark(false) }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts deleted file mode 100644 index d18017cae..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Tree.ts - * 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림. - * 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키). - * ========================================================================== */ - -import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch"; -import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api"; - -/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */ -export const pathOf = (c: ComboSummary): string[] => - c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : []; - -/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */ -export const toView = (list: ComboList, note?: TreeNote): TreeView => ({ - 판: list.판, - 폴더: list.폴더, - items: list.줄.map((c) => ({ - key: c.키, - 열: c.키, - 경로: [...pathOf(c), c.이름], - 이름: c.이름, - 단위: c.단위, - })), - ...(note ? { 알림: note } : {}), -}); - -/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */ -export function noteOf(edit: TreeEdit, gone: string[]): TreeNote { - const at = edit.경로 ?? []; - const last = at[at.length - 1] ?? ""; - if (edit.키 && edit.op !== "delete") { - const where = edit.op === "move" ? (edit.새경로 ?? []) : at; - return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone }; - } - const path = - edit.op === "add-folder" - ? [...at, edit.이름 ?? ""] - : edit.op === "rename" - ? [...at.slice(0, -1), edit.이름 ?? ""] - : edit.op === "move" - ? [...(edit.새경로 ?? []), last] - : undefined; - return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 426b52b8d..014e9c9b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -81,18 +81,12 @@ export interface TreePanelOptions { dest: string[], ) => Promise; }; - /** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */ - clipKind?: string; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ copyTo?: { layer: Layer; owner: string | null; label: string }[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; - /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ - maxDepth?: number; - /** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */ - item?: { add: string; name: string; onAdd?: (path: string[]) => void }; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ @@ -123,7 +117,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const owner = opt.owner ?? null; const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree }; - const clipKind = opt.clipKind ?? "구조물"; + const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임 let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; @@ -213,13 +207,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(target.kind === "folder" ? [ - ...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth - ? [] - : [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]), - { - label: opt.item?.add ?? L("M02_TreeAddItem"), - run: () => void addItem(target.path), - }, + { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, + { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, ] : []), ...(isTop @@ -466,7 +455,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function addItem(parent: string[]): Promise { if (!(await ready())) return; - if (opt.item?.onAdd) return opt.item.onAdd(parent); // 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2) const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : []; const got = await askNewItem({ @@ -490,10 +478,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const got = await askText(L("M02_TreeRename"), [ { key: "name", - label: - target.kind === "item" - ? (opt.item?.name ?? L("M02_TreeItemName")) - : L("M02_TreeFolderName"), + label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"), value: nameOf(target), }, ]); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 2be94a20d..a19bc482a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -27,7 +27,6 @@ import { type OpenTarget, type Root, } from "./M02_MasterTemplete_Layer"; -import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { headRenameEdit, pickCombo, @@ -86,14 +85,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - // 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 · - // 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로) - let view: OpenTarget = entry.open; - const mark = (open: OpenTarget): void => { - view = open; - const query = new URLSearchParams(entryQuery({ ...entry, open })); - history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); - }; const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; @@ -102,9 +93,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void): HTMLElement => createLayerBar({ user, @@ -127,11 +118,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void Promise.all([side.refresh(), tree.refresh()]), - () => { - side.paint(); - // 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음) - if (main.current() && view === "일위대가") mark("구조물조합"); - }, + () => side.paint(), { top: readOnlyBar?.root, search: tree.search, @@ -168,19 +155,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - labor: ({ onOpen }) => - buildLaborSection({ - show: main.showView, - confirmLeave: main.confirmLeave, - onOpen: () => { - onOpen(); - mark("일위대가"); - }, - bar: (add, back) => layerBar("일위대가", add, back), - wrap: (panel) => peopleTree("일위대가", panel), - make: (reveal) => - buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }), - }), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); @@ -200,8 +174,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; - /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ - labor?: (hooks: { onOpen: () => void }) => LaborSection; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; } @@ -85,7 +82,7 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; - /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ + /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ reveal: (target: "옵션" | "도면" | "일위대가") => void; } @@ -136,7 +133,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), labor?.clear(), paint()), + onOpen: () => (options.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -148,9 +145,8 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), labor?.clear(), paint()), + onOpen: () => (matset.clear(), paint()), }); - const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { @@ -181,15 +177,9 @@ export function buildSide(opt: SideOptions): SideHandle { : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [ - ...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []), - summary, - ...(extras ? [extras.옵션, extras.도면] : []), - ], + children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], }); attachCollapsible(listHost); - // 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침) - labor?.body.closest("section")?.classList.remove("is-collapsed"); const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); @@ -263,7 +253,6 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); - if (opt.getOpen()) labor?.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) @@ -388,10 +377,7 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") { - labor?.body.closest("section")?.classList.remove("is-collapsed"); - return void labor?.open(); - } + if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0e323ca8c..c4c51d1ca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -222,14 +222,6 @@ font-size: 11px; } -/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */ -.m02-labor { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - .m02-main__head + .m02-tree__search { margin-bottom: var(--spacing-8); } diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 9268309c8..755dac40b 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -1,124 +1,35 @@ -"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정. +"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · +일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA). -못박는 것 - - 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키. - - 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 · - 지우기는 고름 없음. +옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음. """ -import json -import shutil -import subprocess from pathlib import Path -import pytest - ROOT = Path(__file__).resolve().parents[2] -SCRIPT = r""" -const fs = require("fs"); -const ts = require(process.argv[1] + "/config/node_modules/typescript"); -const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts"; -const src = fs.readFileSync(process.argv[1] + file, "utf8"); -const js = ts.transpileModule(src, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; -const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" }); -const list = { - 판: "v1", - 폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]], - 줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null), - row("UP1-000003", "다", null, "무시")], -}; -const view = m.toView(list); -const notes = [ - { op: "add-folder", 경로: ["석축"], 이름: "메쌓기" }, - { op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" }, - { op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] }, - { op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] }, - { op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" }, - { op: "delete", 경로: ["석축"], 키: "UP1-000002" }, - { op: "delete", 경로: ["빈"] }, -].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : [])); -console.log(JSON.stringify({ view, notes })); -""" +M02 = ROOT / "M02_MasterTemplete" -@pytest.fixture(scope="module") -def got(): - if shutil.which("node") is None: - pytest.skip("node 가 없음") - proc = subprocess.run( - ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) +def _text(name: str) -> str: + return (M02 / name).read_text("utf-8") -def test_조합이_나무_항목으로(got): - items = got["view"]["items"] - assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]] - assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"] - assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]] - assert "알림" not in got["view"] +def test_일위대가_나무_파일과_이음이_없음(): + assert not (M02 / "M02_MasterTemplete_Labor.ts").exists() + assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists() + page = _text("M02_MasterTemplete_UI_Page.ts") + assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page + side = _text("M02_MasterTemplete_UI_Side.ts") + assert "LaborSection" not in side and "labor?." not in side -def test_나무_고침_알림(got): - add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"] - assert add["경로"] == ["석축", "메쌓기"] and "열" not in add - assert rename["경로"] == ["석축", "찰"] - assert move["경로"] == ["찰쌓기"] - assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"] - assert rename_item["경로"] == ["석축", "나나"] - assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"] - assert "경로" not in delete_folder +def test_나무_부품에_일위대가_전용_칸_없음(): + panel = _text("M02_MasterTemplete_TreePanel.ts") + for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"): + assert gone not in panel, gone -def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): - """브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) · - 일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시 - `open=일위대가`(다시 그리지 않고 주소만).""" - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길 - assert ( - 'bar: (add, back) => layerBar("일위대가", add, back),' in page - and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - ) - labor = page.split("buildLaborSection({")[1].split("make:")[0] - assert 'mark("일위대가")' in labor # 여는 순간 해시 open - assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림) - assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page - - -def test_일위대가_나무에_여러_개_복사_붙여넣기_연결(): - """PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 · - 붙임 종류는 따로(구조물과 안 섞임).""" - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor - assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기 - assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층 - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록 - - -def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표(): - """PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선). - 머리 줄도 공용 표 모양.""" - edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8") - assert ( - "newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit - ) - assert ( - 'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit - ) # 목록 안 옛 자리 - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor - main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") - assert ( - 'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))' - in main - ) - css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8") - assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css +def test_층_단추는_구조물_조합_한_벌(): + page = _text("M02_MasterTemplete_UI_Page.ts") + assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 + assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c7bb3f17f..299ec5e4b 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -136,7 +136,7 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page - assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page + assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움 panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( "api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel @@ -219,17 +219,13 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): - """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · - 저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · - 끌어다 옮기기는 쓸 수 있는 층만.""" + """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · + 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page - labor = _text(M02 / "M02_MasterTemplete_Labor.ts") - assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") @@ -259,10 +255,9 @@ def test_구조물_조합_제목_줄_새_항목(): def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움(): - """31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸 + """31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로).""" side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") - assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side assert 'summary.classList.remove("is-collapsed");' in side drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") mark = drag.split("const dropMark = ")[1].split("};")[0] From 8739148bba03d03b7bbc8287f5fc9fc28fa67cab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:23:01 +0900 Subject: [PATCH 06/36] =?UTF-8?q?feat(spreadsheet):=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20setLink=20=E2=80=94=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EC=B9=B8=EC=97=90=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=ED=92=80=EA=B8=B0=20=C2=B7=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=90=A8=20(PLAN=2039-4=20D4)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet.ts | 13 +++ .../spreadsheet/spreadsheet_link_set.ts | 55 +++++++++++ .../spreadsheet/spreadsheet_link_set_entry.ts | 92 +++++++++++++++++++ resources/tester/test_spreadsheet_set_link.py | 68 ++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_set.ts create mode 100644 resources/tester/spreadsheet/spreadsheet_link_set_entry.ts create mode 100644 resources/tester/test_spreadsheet_set_link.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 87ec2cc7b..d452f6b95 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; import type { ConditionOptions } from "./spreadsheet_condition"; import type { LinkOptions, Links } from "./spreadsheet_links"; +import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; @@ -48,6 +49,7 @@ import type { export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types"; export type { LinkItem, LinkOptions } from "./spreadsheet_links"; +export type { LinkInfo } from "./spreadsheet_link_set"; export interface SpreadsheetOptions { /** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */ @@ -76,6 +78,9 @@ export interface SpreadsheetHandle { recalc(): CalcValues; /** 알약 키 → 연결 칸 값(빈 칸 0) */ getLinkedValues(): Record; + /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · + * 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo): boolean; destroy(): void; } @@ -288,6 +293,14 @@ export function createSpreadsheet( } return out; }, + setLink(key, cell, info) { + if (readOnly) return false; + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + if (command === false) return false; + if (command) dispatch(command); + loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts new file mode 100644 index 000000000..8f740992c --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * spreadsheet_link_set.ts (주인 D) + * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · + * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * ========================================================================== */ + +import { parseA1, toA1 } from "./spreadsheet_address"; +import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types"; + +/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */ +export interface LinkInfo { + 이름?: string; + 단위?: string; + 자재?: LinkMaterial; +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +export function linkCommand( + book: Workbook, + sheetId: string, + key: string, + cell: string | null, + info: LinkInfo = {}, +): Command | null | false { + const at = cell === null ? null : parseA1(cell.trim().toUpperCase()); + if (cell !== null && !at) return false; + const a1 = at ? toA1(at.r, at.c) : null; + const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key); + const home = book.시트.find((s) => s.id === sheetId); + if (a1 && !home) return false; + if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 + const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null; + if (one) { + const 이름 = info.이름 ?? old?.이름; + const 단위 = info.단위 ?? old?.단위; + const 자재 = info.자재 ?? old?.자재; + if (이름) one.이름 = 이름; + if (단위) one.단위 = 단위; + if (자재) one.자재 = 자재; + } + const commands: Command[] = []; + for (const s of book.시트) { + const was = s.연결 ?? []; + const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1)); + const next = s === home && one ? [...rest, one] : rest; + if (next.length === was.length && next.every((l, i) => l === was[i])) continue; + const copy = structuredClone(s); + if (next.length) copy.연결 = next; + else delete copy.연결; + commands.push({ 종류: "시트통째", 시트: copy }); + } + if (!commands.length) return null; + return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; +} diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts new file mode 100644 index 000000000..ce15d5b73 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -0,0 +1,92 @@ +/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; +import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +const book = (): Workbook => + ({ + 종류: "통합문서", + 판: 1, + 열: "t", + 서식: [{}], + 시트: [ + { + id: "s1", + 이름: "면적산출", + 칸: { B1: { 값: 12.5 } }, + 연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }], + }, + { id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] }, + ], + 활성: "s1", + }) as unknown as Workbook; +const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null); +const run = (b: Workbook, c: Command | null | false) => { + if (!c) return null; + return applyCommand(b, c).undo; +}; + +// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음 +let b = book(); +const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" }; +let undo = run( + b, + linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }), +); +eq("새 연결", b.시트[0].연결?.[1], { + 키: "자재|MT000008", + 칸: "D2", + 이름: "치즐", + 단위: "EA", + 자재: mt, +}); +// 되돌리기 = 처음 그대로 +run(b, undo); +eq("되돌림", links(b), links(book())); + +// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); +eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); + +// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); +eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); + +// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 +b = book(); +undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1")); +eq("시트 넘어 옮김", links(b), [ + [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "치기|㎥", 칸: "A1", 이름: "치기" }, + ], + null, +]); +run(b, undo); +eq("시트 넘어 되돌림", links(b), links(book())); + +// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", null)); +eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]); + +// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false +b = book(); +eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null); +eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null); +eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false); +eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); +eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); +eq("문서 그대로", links(b), links(book())); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py new file mode 100644 index 000000000..13e3ba179 --- /dev/null +++ b/resources/tester/test_spreadsheet_set_link.py @@ -0,0 +1,68 @@ +"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4). + +순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 — +새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소. +손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_setLink_순수_길(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 13 + + +def test_손잡이_setLink_몸(): + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src + body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert "if (readOnly) return false;" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) + assert "loadLinks();" in body From 0b158636ccb6d8233aac4a914db2ae472e91f254 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:23:56 +0900 Subject: [PATCH 07/36] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B8=20=EC=A7=80=EC=A0=95=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20AreaLink=20=E2=80=94=20=EC=A7=80=EA=B8=88=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A1=9C=20=EC=9E=87=EA=B8=B0=20=C2=B7=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A0=95=EB=B3=B8=EC=9D=80=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EB=AC=B8=EC=84=9C=20=EC=97=B0=EA=B2=B0(setLink)=20?= =?UTF-8?q?(PLAN=2039-4=20D5)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_AreaLink.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts new file mode 100644 index 000000000..9bd76f293 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaLink.ts + * 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶. + * 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기. + * 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라 + * 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장]. + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; + +const TEXT = { + title: "칸 지정 — {value}", + close: "닫기", + now: "지금 연결", + none: "연결 없음", + picked: "지금 고른 칸으로 ({value})", + address: "칸 주소", + hint: "예 B6", + link: "잇기", + unlink: "풀기", + bad: "칸 주소가 틀림 — 예 B6", + readOnly: "읽기만 — 고칠 수 없음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaLinkOptions { + /** 줄 이름(모달 제목) */ + label: string; + /** 알약 키 */ + key: string; + /** 연결에 같이 적을 이름 · 단위 · 자재 */ + info: LinkInfo; + sheet: SpreadsheetHandle; + /** 시트에서 지금 고른 칸(A1) */ + picked: () => string; + readOnly: boolean; +} + +/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */ +function linkedText(opts: AreaLinkOptions): string | null { + const link = opts.sheet + .getDoc() + .시트.flatMap((s) => s.연결 ?? []) + .find((l) => l.키 === opts.key); + if (!link) return null; + const value = opts.sheet.getLinkedValues()[opts.key]; + return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim(); +} + +export function openAreaLink(opts: AreaLinkOptions): void { + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-area-link", + mount: (body) => { + const now = el("p", { className: "m02-area-link__now" }); + const paint = (): void => { + const text = linkedText(opts); + now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; + unlink.disabled = opts.readOnly || !text; + }; + const done = (cell: string | null): boolean => { + if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + close(); + return true; + }; + const here = opts.picked(); + const pick = createButton({ + label: fill(TEXT.picked, here), + variant: "filled", + size: "sm", + onClick: () => void done(here), + }); + const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint }); + const link = createButton({ + label: TEXT.link, + variant: "ghost", + size: "sm", + onClick: () => { + if (!done(address.input.value)) address.setError(TEXT.bad); + }, + }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => void done(null), + }); + address.input.addEventListener("input", () => address.setError(undefined)); + address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click()); + pick.disabled = link.disabled = address.input.disabled = opts.readOnly; + paint(); + body.append( + now, + ...(opts.readOnly + ? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })] + : []), + el("div", { className: "m02-area-link__row", children: [pick, unlink] }), + el("div", { className: "m02-area-link__row", children: [address.root, link] }), + ); + if (!opts.readOnly) address.input.focus(); + }, + }); +} From 18a063925351ff1e5b72deddf1b7ecaf3522859c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:24:00 +0900 Subject: [PATCH 08/36] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=20=EB=AA=A9=EB=A1=9D=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=E2=80=94=20M01=20=EC=B0=BE=EA=B8=B0=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EC=AA=BD=20=C2=B7=20=ED=91=9C=20=EA=BC=B4=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=A7=88=EB=8B=A4=20=EC=B9=B8(=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=ED=86=B5=EC=A7=B8)=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=B9=B8=20=EC=88=98=EC=A0=95=EC=9D=BC?= =?UTF-8?q?=20=C2=B7=20labor(=EB=B2=88=ED=98=B8=20=C2=B7=20=EB=8B=B4?= =?UTF-8?q?=EC=9D=80=20=EB=A1=9C=EC=A7=81=20=EC=88=98=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H1 · H6 — 전체 항목 · 복사 생성 · 항목 하나 복사가 같이 쓸 목록 하나. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 182 ++++++++++++++++++ .../M02_MasterTemplete_Items.py | 40 +++- resources/tester/helper_m02_item_list.cjs | 65 +++++++ resources/tester/test_m02_item_list.py | 94 +++++++++ resources/tester/test_m02_layers_store.py | 8 +- 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemList.ts create mode 100644 resources/tester/helper_m02_item_list.cjs create mode 100644 resources/tester/test_m02_item_list.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts new file mode 100644 index 000000000..363c48e5c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemList.ts + * 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나 + * 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 + * (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음). + * 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다. + * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). + * ========================================================================== */ + +import { createInputField, el } from "@ui/ui_template_elements"; +import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; +import "../M01_MasterData/M01_MasterData_UI_Style.css"; +import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; +import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model"; + +/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */ +export type ListKind = "structure" | "area" | "labor" | "item"; + +/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */ +export interface ItemRow extends TreeItem { + 도번?: string | null; + 면적번?: string | null; + 수정일?: Partial>; + labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; +} + +export interface Col { + head: string; + get: (row: R) => string; +} + +const TEXT = { + search: "찾기", + none: "항목이 없음", + noMatch: "맞는 항목이 없음", + loading: "불러오는 중", +}; + +const SIZE = 30; +const DASH = "—"; +const day = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const money = (value: number | null | undefined): string => + typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; +/** 가장 늦게 고친 날 — 항목 통째 */ +const latest = (row: ItemRow): string => + day( + Object.values(row.수정일 ?? {}) + .sort() + .pop(), + ); + +const NAME: Col = { head: "이름", get: (r) => leafName(r) }; +const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; +const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; +const changed = (part: "structure" | "area" | "labor"): Col => ({ + head: "고친 날", + get: (r) => day(r.수정일?.[part]), +}); + +/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */ +export const ITEM_COLS: Record[]> = { + structure: [ + { head: "도번", get: (r) => r.도번 || DASH }, + NAME, + FOLDER, + UNIT, + changed("structure"), + ], + area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")], + labor: [ + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + NAME, + FOLDER, + UNIT, + { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, + { head: "합계", get: (r) => money(r.labor?.합계) }, + changed("labor"), + ], + item: [ + NAME, + FOLDER, + UNIT, + { head: "도번", get: (r) => r.도번 || DASH }, + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + { head: "면적번", get: (r) => r.면적번 || DASH }, + { head: "고친 날", get: latest }, + ], +}; + +/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ +export function pageOf( + rows: R[], + cols: Col[], + query: string, + page: number, + size = SIZE, +): { shown: R[]; page: number; total: number } { + const hits = rows.filter((r) => + matchText( + cols.map((c) => c.get(r)), + query, + ), + ); + const last = Math.max(1, Math.ceil(hits.length / size)); + const at = Math.min(Math.max(1, page), last); + return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length }; +} + +/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */ +export const loadItems = async (layer: Layer, owner: string | null): Promise => + (await fetchTree(layer, owner)).items as ItemRow[]; + +export interface ItemListHandle { + root: HTMLElement; + /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ + show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ + message: (text?: string) => void; +} + +export function buildItemList(): ItemListHandle { + let draw = (): void => {}; + let page = 1; + const searchField = createInputField({ type: "search", placeholder: TEXT.search }); + const search = searchField.input; + const grid = el("div", { className: "m01-master__grid-wrap" }); + let pager = buildPager(0, SIZE, 1, () => {}); + const note = (text: string): void => { + grid.replaceChildren(el("p", { className: "m01-master__empty", text })); + }; + + const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + page = 1; + draw = (): void => { + const got = pageOf(rows, cols, search.value, page); + page = got.page; + const next = buildPager(got.total, SIZE, page, (p) => { + page = p; + draw(); + }); + pager.replaceWith(next); + pager = next; + if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none); + const body = got.shown.map((row) => { + const tr = el("tr", { + className: "m01-master__table-row", + children: cols.map((c) => { + const text = c.get(row); + return el("td", { text, attrs: { title: text } }); + }), + }); + tr.addEventListener("click", () => onPick(row)); + return tr; + }); + grid.replaceChildren( + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })], + }), + el("tbody", { children: body }), + ], + }), + ); + }; + draw(); + }; + + search.addEventListener("input", () => { + page = 1; + draw(); + }); + const root = el("div", { + className: "m02-itemlist", + children: [buildHeadFilters([searchField.root, pager]), grid], + }); + // 처음 = 빈 표 자리(줄이 오면 `show`) + note(TEXT.loading); + return { root, show, message: (text = TEXT.loading) => note(text) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 90f0dacd7..385c46d28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from datetime import datetime from pathlib import Path from typing import Any @@ -85,10 +86,47 @@ def items(folder: Path) -> list[dict[str, Any]]: k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번") }, } - out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names}) + out.append( + { + "key": r.get("키"), + **{k: r.get(k) for k in ITEM_FIELDS}, + "names": names, + **_list_extras(Path(folder), str(r.get("열"))), + } + ) return out +def _list_extras(folder: Path, col: str) -> dict[str, Any]: + """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + stamps: dict[str, str] = {} + for kind in ("structure", "area", "labor"): + path = folder / kind / f"{col}.json" + if path.is_file(): + stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat( + timespec="seconds" + ) + labor = None + path = folder / "labor" / f"{col}.json" + if path.is_file(): + try: + doc = json.loads(path.read_bytes()) + except ValueError: + doc = {} + total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") + try: + total = None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + total = None # 수 아닌 값(옛 꼴)은 「—」 + labor = { + "번호": doc.get("일위대가번"), + "로직수": len(doc.get("담은로직") or []), + "합계": total, + } + return {"수정일": stamps, "labor": labor} + + def _fresh_id(want: str, used: set[str]) -> str: out, n = want, 2 while out in used: diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs new file mode 100644 index 000000000..875074b62 --- /dev/null +++ b/resources/tester/helper_m02_item_list.cjs @@ -0,0 +1,65 @@ +/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 + * JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete"); +const load = (name, stubs) => { + const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const need = (id) => (id in stubs ? stubs[id] : {}); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +}; +const model = load("M02_MasterTemplete_TreePanel_Model.ts", {}); +const m = load("M02_MasterTemplete_ItemList.ts", { + "./M02_MasterTemplete_TreePanel_Model": model, +}); + +const row = (열, 경로, extra = {}) => ({ + key: `SS-${열}`, + 열, + 경로, + 이름: 경로.join(" "), + 단위: "m", + 도번: `SD-${열}`, + 면적번: `SA-${열}`, + 수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" }, + labor: null, + ...extra, +}); +const rows = [ + row("c1", ["기슭막이", "메쌓기", "1.2m"], { + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, + }), + row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), + row("c3", ["옹벽"], { 도번: "", 수정일: {} }), +]; +const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r))); +const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`])); + +process.stdout.write( + JSON.stringify({ + heads: Object.fromEntries( + Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]), + ), + cells: { + structure: cells("structure"), + area: cells("area"), + labor: cells("labor"), + item: cells("item"), + }, + find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열), + findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열), + findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1), + page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))( + m.pageOf(many, m.ITEM_COLS.structure, "", 3), + ), + pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, + pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + }), +); diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py new file mode 100644 index 000000000..63b809bd7 --- /dev/null +++ b/resources/tester/test_m02_item_list.py @@ -0,0 +1,94 @@ +"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) · +서버 목록 칸(임시 storage). + +① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 · + 고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽. +② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 · + 합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +from resources.tester import test_m02_tree as base +from resources.tester.test_m02_tree import Tree, _as + +world = base.world # 같은 임시 storage 판 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_item_list.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_종류마다_칸(ts): + assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["labor"] == [ + "일위대가번", + "이름", + "폴더", + "단위", + "담은 로직 수", + "합계", + "고친 날", + ] + assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), []) + + +def test_칸_값_빈_번호_합계_날짜(ts): + s, labor = ts["cells"]["structure"], ts["cells"]["labor"] + assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] + assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) + assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 + assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 + + +def test_찾기_쪽(ts): + assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) + assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 + assert ts["findNone"] == {"shown": [], "page": 1, "total": 0} + assert ts["page3"] == {"page": 3, "total": 65, "n": 5} + assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 + + +def test_서버_목록_칸_수정일_labor(world): + t = Tree(_as(world), "personal") + t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") + t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m") + root = world["storage"] / "7" / "42" / "templates" + ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"]) + assert set(ditch["수정일"]) >= {"structure", "area"} + assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO + assert ditch["labor"] is None # 일위대가 파일 없음 + (root / "labor").mkdir(exist_ok=True) + doc = { + "일위대가번": "SU-000001", + "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], + "계산값": {"반영합": {"계": 12345.6}}, + } + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") + rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert "labor" in rows[ditch["열"]]["수정일"] + assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + doc["계산값"] = {"반영합": {"계": "글"}} + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert again[ditch["열"]]["labor"]["합계"] is None diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index 1be487e98..9398233f1 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -77,7 +77,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world): rows = client.get("/api/m02/items").json() ss = store.read_combos()["줄"] assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001" - assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS} + assert set(rows[0]) == { + "key", + "names", + "수정일", # 목록 칸(39-5) + "labor", + *items_router.items_store.ITEM_FIELDS, + } assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"} assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos() # 시스템 층 쓰기는 SYSTEM_ADMIN 만 From 134a727c9cf6e98832496cc162f4faf3b87225c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:27:02 +0900 Subject: [PATCH 09/36] =?UTF-8?q?feat(M02):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20M01=20=EB=A8=B8=EB=A6=AC=20=EA=BC=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=9C=EB=AA=A9=2018px=20=EC=99=BC=EC=AA=BD=20?= =?UTF-8?q?=C2=B7=20=E3=80=8C=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0=E3=80=8D?= =?UTF-8?q?=20=EB=8A=94=20=EC=A0=9C=EB=AA=A9=20=EC=95=9E=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 마스터 템플릿 상세 머리 전부(구조물 조합 · 도면 · 면적산출 · 일위대가 · 양식 밖 화면). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts | 3 ++- M02_MasterTemplete/M02_MasterTemplete_UI_Style.css | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 480f213f2..522751dc9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -152,7 +152,8 @@ export function createMain( ...(extras.top ? [extras.top] : []), el("div", { className: "m02-main__head", - children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], + // M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂) + children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], }), ...(extras.search ? [extras.search] : []), notice, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0e323ca8c..4b4fa823a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -42,7 +42,7 @@ overflow-wrap: anywhere; /* 번호 + 이름 — 잘리지 않게 줄바꿈 */ margin: 0; color: var(--color-text); - font-size: var(--text-body); + font-size: 18px; /* M01 머리 제목과 같음(39-5) */ } .m02-main__head .ui-btn { From 43a59a68932d1d80e4ae7779d2e9bf44c107ab0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:27:30 +0900 Subject: [PATCH 10/36] =?UTF-8?q?refactor(M02):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EB=84=A3=EC=9D=84=20=EA=B0=92=20=EC=B9=B8=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EC=98=AE=EA=B9=80(39-3=20C2)=20=E2=80=94=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5=EC=97=90=EC=84=9C=20M02?= =?UTF-8?q?=5FLabor=5FInputs.py=20=C2=B7=20M02=5FLabor=5FValues(=5FModel).?= =?UTF-8?q?ts=20=EB=A1=9C=20=C2=B7=20=EC=98=9B=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EB=8B=A4=EC=8B=9C=20=EB=82=B4=EB=B3=B4=EB=83=84=20?= =?UTF-8?q?=C2=B7=20=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20=EA=B8=B8=20?= =?UTF-8?q?logic-lib/inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: inputs · combo_logics · _num 몸을 M02_Labor_Inputs.py 로(logic_inputs = 로직 키들 입력 + 자동값) · M02_MasterTemplete_Inputs.py 는 그것을 부름 - 길: GET /api/m02/logic-lib/inputs?keys= (로그인 · 일위대가 입력 모달용) - 화면: 값 카드(createInputsValueCard) · 값 함수 → M02_Labor_Values.ts · M02_Labor_Values_Model.ts · 값 칸 하나(createValueControl)를 떼어 값 카드와 입력 모달이 같이 씀 · CSS 도 옮김 - 옛 자리(M02_MasterTemplete_Inputs.ts · _Inputs_Model.ts)는 새 파일을 다시 내보냄 — B05 고르기 모달 그대로 - 시험: 새 test_m02_labor_values.py · 옮긴 자리 따라 test_item_picker_detail · test_m02_card_shell · helper_m02_inputs 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Inputs.py | 94 ++++++ M02_MasterTemplete/M02_Labor_Values.css | 99 ++++++ M02_MasterTemplete/M02_Labor_Values.ts | 316 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Values_Model.ts | 116 +++++++ .../M02_MasterTemplete_Inputs.css | 98 ------ .../M02_MasterTemplete_Inputs.py | 65 +--- .../M02_MasterTemplete_Inputs.ts | 299 +---------------- .../M02_MasterTemplete_Inputs_Model.ts | 58 +--- .../M02_MasterTemplete_Router_Combos.py | 7 + resources/tester/helper_m02_inputs.cjs | 19 +- resources/tester/test_item_picker_detail.py | 9 +- resources/tester/test_m02_card_shell.py | 4 +- resources/tester/test_m02_labor_values.py | 101 ++++++ 13 files changed, 770 insertions(+), 515 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Inputs.py create mode 100644 M02_MasterTemplete/M02_Labor_Values.css create mode 100644 M02_MasterTemplete/M02_Labor_Values.ts create mode 100644 M02_MasterTemplete/M02_Labor_Values_Model.ts create mode 100644 resources/tester/test_m02_labor_values.py diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py new file mode 100644 index 000000000..068c64b33 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -0,0 +1,94 @@ +"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김). + +담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은 +그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 = +`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과 +옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +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_MasterTemplete_Combo_Store as combos + +_SPEC = ("이름", "설명", "단위", "고르기", "범위") + + +def _num(value: Any) -> Any: + """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_num(v) for v in value] + if isinstance(value, dict): + return {k: _num(v) for k, v in value.items()} + return value + + +def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: + """([로직 줄 …], 빠진 로직 키) — 키 차례 그대로.""" + whole = m01.loaded()[1] + found, missing = [], [] + for code in codes: + try: + found.append(whole.logic(code)) + except combo_store.cm.mf.FormulaError: + missing.append(code) + return found, missing + + +def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: + """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" + if not ua: + return None, [], [] + row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c) + if row is None: + return None, [], [] + found, missing = _logics(combo_store.mcb.logic_keys(row)) + return row, found, missing + + +def _listed(logics: list[dict]) -> list[dict[str, Any]]: + """로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`.""" + whole = m01.loaded()[1] + out = [] + for logic in logics: + auto = m01.mau.values(whole, logic) + specs = [] + for spec in logic.get("입력") or []: + one = {k: spec[k] for k in _SPEC if k in spec} + one.setdefault("설명", None) + one.setdefault("단위", None) + specs.append({**one, "자동값": auto.get(spec["이름"])}) + out.append( + { + "로직": logic.get("키"), + "이름": logic.get("이름"), + "결과단위": logic.get("결과단위"), + "입력": specs, + } + ) + return out + + +def logic_inputs(codes: list[str]) -> dict[str, Any]: + """일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)""" + found, missing = _logics(list(dict.fromkeys(c for c in codes if c))) + return _num({"로직": _listed(found), "빠진로직": missing}) + + +def inputs_of(ua: str | None) -> dict[str, Any]: + """옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음.""" + row, logics, missing = combo_logics(ua) + return _num( + { + "일위대가": ua or None, + "이름": row.get("이름") if row else None, + "로직": _listed(logics), + "빠진로직": missing, + } + ) diff --git a/M02_MasterTemplete/M02_Labor_Values.css b/M02_MasterTemplete/M02_Labor_Values.css new file mode 100644 index 000000000..dca291790 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.css @@ -0,0 +1,99 @@ +/* 넣을 값 칸(39-3 · 옛 선택 입력 CSS 에서 옮김) — 프로젝트 값 카드 · 일위대가 입력 모달이 같이 씀. 색은 테마 토큰. */ + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ +.m02-inputval__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputval { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputval__places, +.m02-inputval__missing { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-inputval__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputval__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__control { + flex: 1 1 6rem; + min-width: 0; + max-width: 24rem; +} + +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ +.m02-inputval__value.is-auto, +.m02-inputval__control.is-auto, +.m02-inputval__control::placeholder { + color: var(--color-text-secondary); + font-style: italic; + opacity: 1; +} + +.m02-inputval__state { + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputval__state.is-confirmed { + color: var(--color-text); + font-weight: 600; +} + +.m02-inputval__error { + flex: 1 0 100%; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-inputval__control[aria-invalid="true"] { + border-color: var(--color-danger); +} diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts new file mode 100644 index 000000000..a7453b93a --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -0,0 +1,316 @@ +/* ============================================================================= + * M02_Labor_Values.ts + * 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌: + * 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder). + * · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀) + * · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch"; +import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; +import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model"; +import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; + +const fill = (text: string, values: Record): string => + text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); + +const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; + +const autoValue = (input: ItemInput): string | number | null => { + const auto = input.자동값; + return typeof auto === "string" || typeof auto === "number" ? auto : null; +}; + +export interface ValueControl { + control: HTMLInputElement | HTMLSelectElement; + /** 범위 밖 글(빈 글이면 비움) */ + error: HTMLElement; + /** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */ + wasBad: boolean; +} + +/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */ +export function createValueControl( + input: ItemInput, + value: string | number | null, + onChange: (value: string | number | null, bad: boolean) => void, +): ValueControl { + const auto = autoValue(input); + const range = rangeOf(input); + const choices = choicesOf(input); + const control = ( + choices.length + ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) + : el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: range ? "number" : "text", + ...(range?.min !== undefined ? { min: String(range.min) } : {}), + ...(range?.max !== undefined ? { max: String(range.max) } : {}), + ...(range ? { step: "any" } : {}), + placeholder: auto === null ? "" : String(auto), + }, + }) + ) as HTMLInputElement | HTMLSelectElement; + if (control instanceof HTMLSelectElement) { + control.append( + el("option", { + attrs: { value: "" }, + text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, + }), + ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ); + } + const empty = value === null || value === ""; + control.value = empty ? "" : String(value); + control.classList.toggle("is-auto", empty); + const error = el("span", { className: "m02-inputval__error" }); + const out: ValueControl = { control, error, wasBad: false }; + control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => { + const raw = control.value; + const bad = isOutside(input, raw); + control.setAttribute("aria-invalid", bad ? "true" : "false"); + error.textContent = bad + ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) + : ""; + control.classList.toggle("is-auto", raw === ""); + onChange(storedOf(input, raw), bad); + }); + return out; +} + +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── + * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 + * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 + * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. + * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ + +export interface InputsValueData { + list: ItemInputs; + link: InputLinks | null; + values: ValueDoc; +} + +export interface InputsValueOptions { + mode: "view" | "value"; + /** mode "view" — [선택 입력 수정] */ + onEdit?: () => void; + /** mode "value" — 값이 바뀔 때마다 */ + onChange?: (values: ValueDoc) => void; +} + +export interface InputsValueHandle { + root: HTMLElement; + /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ + set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; + /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ + loading: () => () => void; + /** 지금 값(확인한 것 · 값이 든 것만) */ + getValues: () => ValueDoc; + /** 범위 밖 값이 없나 */ + isValid: () => boolean; + /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ + setEditLabel: (label: string) => void; +} + +const VALUE_TEXT = { + title: "선택 입력", + missing: "빠진 로직 {value}", + edit: "선택 입력 수정", + none: "선택으로 이은 입력이 없음", + auto: "자동값", + unconfirmed: "확인 안 함", + confirmed: "✓ 확인함", + confirmBox: "확인", + summary: "선택 {value}", + places: "면적산출 알약 {area} · 재료산출 알약 {material}", + noList: "입력 목록을 못 읽음 — {value}", +}; + +type ValueEntry = ValueDoc[string][string]; + +export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { + let data: InputsValueData | null = null; + let values: ValueDoc = {}; + let invalid = 0; + + const summary = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); + edit.hidden = opts.mode !== "view"; + if (opts.onEdit) edit.addEventListener("click", opts.onEdit); + const places = el("p", { className: "m02-inputval__places" }); + const body = el("div", { className: "m02-struct__sheet m02-inputval" }); + // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 + const shell = cardShell({ + title: VALUE_TEXT.title, + sub: [summary], + body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), + stateHost: body, + }); + shell.tools.append(edit); + const root = shell.root; + + const groups = (): ReturnType => + data ? pickedGroups(data.list, data.link) : []; + const one = (logic: string, name: string): ValueEntry => + values[logic]?.[name] ?? { 값: null, 확인: false }; + const isEmpty = (value: string | number | null): boolean => value === null || value === ""; + const put = (logic: string, name: string, next: ValueEntry): void => { + (values[logic] ??= {})[name] = next; + }; + const autoOf = autoValue; + + const paintSummary = (): void => { + let total = 0; + let confirmed = 0; + for (const { logic, inputs } of groups()) + for (const input of inputs) { + total += 1; + if (one(logic.로직, input.이름).확인) confirmed += 1; + } + summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; + }; + + /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ + const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { + const stored = one(logic.로직, input.이름); + const auto = autoOf(input); + const state = el("span", { className: "m02-inputval__state" }); + const paintState = (): void => { + const now = one(logic.로직, input.이름); + state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; + state.classList.toggle("is-confirmed", now.확인); + }; + if (opts.mode === "view") { + const text = isEmpty(stored.값) ? auto : stored.값; + const shown = el("span", { + className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, + text: text === null ? "—" : String(text), + attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [shown, state] }); + } + + const emit = (): void => { + paintState(); + paintSummary(); + opts.onChange?.(values); + }; + const range = rangeOf(input); + const field = createValueControl(input, stored.값, (value, bad) => { + if (bad !== field.wasBad) invalid += bad ? 1 : -1; + field.wasBad = bad; + // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) + put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad }); + box.checked = value !== null && !bad; + emit(); + }); + const { control, error } = field; + const box = el("input", { + attrs: { + type: "checkbox", + title: VALUE_TEXT.confirmBox, + "aria-label": VALUE_TEXT.confirmBox, + }, + }) as HTMLInputElement; + box.checked = stored.확인; + // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 + box.addEventListener("change", () => { + const now = one(logic.로직, input.이름); + if (box.checked && isEmpty(now.값) && auto !== null) { + control.value = String(auto); + control.classList.remove("is-auto"); + put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); + } else if (box.checked && isEmpty(now.값)) { + box.checked = false; // 받아들일 값이 없음 + } else { + put(logic.로직, input.이름, { ...now, 확인: box.checked }); + } + emit(); + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); + }; + + const paint = (note?: string, retry?: () => void): void => { + invalid = 0; + paintSummary(); + if (!data) { + places.textContent = ""; + if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); + return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); + } + const seats = [...seatsOf(data.link).values()]; + const area = seats.filter((seat) => seat === "면적").length; + const material = seats.filter((seat) => seat === "재료").length; + places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; + const shown = groups(); + const missing = data.list.빠진로직.length + ? [ + el("p", { + className: "m02-inputval__missing", + text: fill(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }), + }), + ] + : []; + if (!shown.length) { + body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); + return; + } + body.replaceChildren( + ...shown.map(({ logic, inputs }) => + el("section", { + className: "m02-inputval__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputval__logic-name", + text: logic.이름 || logic.로직, + attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, + }), + ...inputs.map((input) => + el("div", { + className: "m02-inputval__row", + attrs: { "data-input": inputKey(logic.로직, input.이름) }, + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: input.설명 ? { title: input.설명 } : {}, + }), + valueCell(logic, input), + ], + }), + ), + ], + }), + ), + ...missing, + ); + }; + + return { + root, + set: (next, note, retry) => { + data = next; + values = next ? structuredClone(next.values) : {}; + paint(note, retry); + }, + loading: () => shell.loading(), + getValues: () => { + // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) + const out: ValueDoc = {}; + for (const [logic, byName] of Object.entries(values)) + for (const [name, entry] of Object.entries(byName)) + if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; + return out; + }, + isValid: () => invalid === 0, + setEditLabel: (label) => { + (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; + }, + }; +} diff --git a/M02_MasterTemplete/M02_Labor_Values_Model.ts b/M02_MasterTemplete/M02_Labor_Values_Model.ts new file mode 100644 index 000000000..046ee7cc8 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values_Model.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * M02_Labor_Values_Model.ts + * 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김. + * 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 · + * 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림). + * ========================================================================== */ + +import type { ItemInput } from "./M02_MasterTemplete_Api_Fetch"; + +const text = (value: unknown): string => + value === null || value === undefined + ? "" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + +/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ +export function valueHint(input: ItemInput): string { + if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + const range = input.범위; + if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; + if (range && typeof range === "object") { + const r = range as Record; + const lo = r.최소 ?? r.min; + const hi = r.최대 ?? r.max; + if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; + } + return text(range); +} + +/** 자동값 글 — 없으면 빈 글 */ +export const autoText = (input: ItemInput): string => text(input.자동값); + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ +export type ValueDoc = Record< + string, + Record +>; + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ +export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; + if (raw && typeof raw === "object") { + const r = raw as Record; + return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; + } + return null; +} + +/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const object = + typeof one === "object" && one !== null ? (one as Record) : null; + const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; + return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + }); +} + +/** 범위 밖 글인가 — 빈 글 · 범위 없는 입력은 아님 */ +export function isOutside(input: ItemInput, raw: string): boolean { + const range = rangeOf(input); + if (!range || raw.trim() === "") return false; + const value = Number(raw); + return ( + !Number.isFinite(value) || + (range.min !== undefined && value < range.min) || + (range.max !== undefined && value > range.max) + ); +} + +/** 칸 글 → 저장 값 — 빈 글 = null(자동값) · 범위 = 수 · 고르기 = 원래 모양 · 그 밖 = 글 */ +export function storedOf(input: ItemInput, raw: string): string | number | null { + if (raw === "") return null; + if (rangeOf(input)) return Number(raw); + return choicesOf(input).find((c) => c.값 === raw)?.raw ?? raw; +} + +/* ── 일위대가 입력값(39-3) — 로직 한 줄의 `{입력 이름: 값}` ───────────────────────────── */ + +export type LaborValues = Record; + +/** 로직 입력 목록에 있는 이름 · 빈 값 아닌 것만 남김 + 사라진 이름(로직 입력이 바뀌어 버려질 값) */ +export function keepKnown( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { kept: LaborValues; lost: string[] } { + const names = new Set(inputs.map((i) => i.이름)); + const kept: LaborValues = {}; + const lost: string[] = []; + for (const [name, value] of Object.entries(values ?? {})) { + if (!names.has(name)) lost.push(name); + else if (value !== "" && value !== null && value !== undefined) kept[name] = value; + } + return { kept, lost }; +} + +/** 줄 아래 작은 글 — 「지역 서울 · 규격 25mm」(넣은 값만 · 입력 차례) + 빈 값 수(자동값도 없는 입력) */ +export function valueLine( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { text: string; empty: number } { + const parts: string[] = []; + let empty = 0; + for (const input of inputs) { + const value = values?.[input.이름]; + if (value !== undefined && value !== "") { + parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`); + } else if (autoText(input) === "") empty += 1; + } + return { text: parts.join(" · "), empty }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css index d90721bea..fff107c95 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -103,101 +103,3 @@ .m02-inputs__seat:disabled { opacity: 0.7; } - -/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ -.m02-inputval__wrap { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: var(--m02-struct-body-min, 16rem); - min-width: 0; - gap: var(--spacing-8); -} - -.m02-inputval { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - overflow: auto; - min-height: 0; - min-width: 0; -} - -.m02-inputval__places, -.m02-inputval__missing { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-inputval__logic { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-inputval__logic-name { - margin: 0; - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__row { - display: grid; - grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); - gap: var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__name { - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__cell { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__control { - flex: 1 1 6rem; - min-width: 0; - max-width: 24rem; -} - -/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ -.m02-inputval__value.is-auto, -.m02-inputval__control.is-auto, -.m02-inputval__control::placeholder { - color: var(--color-text-secondary); - font-style: italic; - opacity: 1; -} - -.m02-inputval__state { - color: var(--color-text-secondary); - font-size: var(--text-caption); - white-space: nowrap; -} - -.m02-inputval__state.is-confirmed { - color: var(--color-text); - font-weight: 600; -} - -.m02-inputval__error { - flex: 1 0 100%; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m02-inputval__control[aria-invalid="true"] { - border-color: var(--color-danger); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py index 91bc32d87..d6c130984 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -14,30 +14,18 @@ M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · from __future__ import annotations import json -from decimal import Decimal from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json -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_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics PLACES = ("면적", "재료", "선택", None) PILL_HEAD = "입력" -_SPEC = ("이름", "설명", "단위", "고르기", "범위") - - -def _num(value: Any) -> Any: - """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" - if isinstance(value, Decimal): - return int(value) if value == value.to_integral_value() else float(value) - if isinstance(value, list): - return [_num(v) for v in value] - if isinstance(value, dict): - return {k: _num(v) for k, v in value.items()} - return value def pill_key(logic: str, name: str) -> str: @@ -56,55 +44,10 @@ def _combo_row(ua: str) -> dict[str, Any] | None: return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c) -def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: - """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" - if not ua: - return None, [], [] - row = _combo_row(str(ua)) - if row is None: - return None, [], [] - whole = m01.loaded()[1] - found, missing = [], [] - for one in combo_store.mcb.items(row): - code = str(one.get("로직") or "") if isinstance(one, dict) else "" - try: - found.append(whole.logic(code)) - except combo_store.cm.mf.FormulaError: - missing.append(code) - return row, found, missing - - def inputs(key: str) -> dict[str, Any]: """25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`. - 일위대가 없는 항목은 로직 빈 목록.""" - ua = _item(key).get("일위대가") - row, logics, missing = combo_logics(ua) - whole = m01.loaded()[1] - out = [] - for logic in logics: - auto = m01.mau.values(whole, logic) - specs = [] - for spec in logic.get("입력") or []: - one = {k: spec[k] for k in _SPEC if k in spec} - one.setdefault("설명", None) - one.setdefault("단위", None) - specs.append({**one, "자동값": auto.get(spec["이름"])}) - out.append( - { - "로직": logic.get("키"), - "이름": logic.get("이름"), - "결과단위": logic.get("결과단위"), - "입력": specs, - } - ) - return _num( - { - "일위대가": ua or None, - "이름": row.get("이름") if row else None, - "로직": out, - "빠진로직": missing, - } - ) + 일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌).""" + return labor_inputs.inputs_of(_item(key).get("일위대가")) # ── 항목 연결 파일 ───────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 8b31ae48f..2b9b8a143 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -10,7 +10,7 @@ * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, @@ -18,28 +18,21 @@ import { getActiveLayer, readInputLinks, saveInputLinks, - type InputLinks, type InputSeat, - type ItemInput, type ItemInputs, - type ItemLogic, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, - choicesOf, countOf, inputKey, linesOf, - pickedGroups, pillsOf, - rangeOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, - type ValueDoc, } from "./M02_MasterTemplete_Inputs_Model"; import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; import "./M02_MasterTemplete_Inputs.css"; @@ -288,286 +281,10 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro } } -/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── - * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 - * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 - * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. - * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ - -export interface InputsValueData { - list: ItemInputs; - link: InputLinks | null; - values: ValueDoc; -} - -export interface InputsValueOptions { - mode: "view" | "value"; - /** mode "view" — [선택 입력 수정] */ - onEdit?: () => void; - /** mode "value" — 값이 바뀔 때마다 */ - onChange?: (values: ValueDoc) => void; -} - -export interface InputsValueHandle { - root: HTMLElement; - /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ - set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; - /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ - loading: () => () => void; - /** 지금 값(확인한 것 · 값이 든 것만) */ - getValues: () => ValueDoc; - /** 범위 밖 값이 없나 */ - isValid: () => boolean; - /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ - setEditLabel: (label: string) => void; -} - -const VALUE_TEXT = { - edit: "선택 입력 수정", - none: "선택으로 이은 입력이 없음", - auto: "자동값", - unconfirmed: "확인 안 함", - confirmed: "✓ 확인함", - confirmBox: "확인", - outOfRange: "범위 {value} 밖", - summary: "선택 {value}", - places: "면적산출 알약 {area} · 재료산출 알약 {material}", - noList: "입력 목록을 못 읽음 — {value}", -}; - -type ValueEntry = ValueDoc[string][string]; - -export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { - let data: InputsValueData | null = null; - let values: ValueDoc = {}; - let invalid = 0; - - const summary = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); - edit.hidden = opts.mode !== "view"; - if (opts.onEdit) edit.addEventListener("click", opts.onEdit); - const places = el("p", { className: "m02-inputval__places" }); - const body = el("div", { className: "m02-struct__sheet m02-inputval" }); - // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 - const shell = cardShell({ - title: TEXT.title, - sub: [summary], - body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), - stateHost: body, - }); - shell.tools.append(edit); - const root = shell.root; - - const groups = (): ReturnType => - data ? pickedGroups(data.list, data.link) : []; - const one = (logic: string, name: string): ValueEntry => - values[logic]?.[name] ?? { 값: null, 확인: false }; - const isEmpty = (value: string | number | null): boolean => value === null || value === ""; - const put = (logic: string, name: string, next: ValueEntry): void => { - (values[logic] ??= {})[name] = next; - }; - const autoOf = (input: ItemInput): string | number | null => { - const auto = input.자동값; - return typeof auto === "string" || typeof auto === "number" ? auto : null; - }; - - const paintSummary = (): void => { - let total = 0; - let confirmed = 0; - for (const { logic, inputs } of groups()) - for (const input of inputs) { - total += 1; - if (one(logic.로직, input.이름).확인) confirmed += 1; - } - summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; - }; - - /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ - const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { - const stored = one(logic.로직, input.이름); - const auto = autoOf(input); - const state = el("span", { className: "m02-inputval__state" }); - const paintState = (): void => { - const now = one(logic.로직, input.이름); - state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; - state.classList.toggle("is-confirmed", now.확인); - }; - if (opts.mode === "view") { - const text = isEmpty(stored.값) ? auto : stored.값; - const shown = el("span", { - className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [shown, state] }); - } - - const emit = (): void => { - paintState(); - paintSummary(); - opts.onChange?.(values); - }; - const range = rangeOf(input); - const choices = choicesOf(input); - const control = ( - choices.length - ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) - : el("input", { - className: "ui-input m02-inputval__control", - attrs: { - type: range ? "number" : "text", - ...(range?.min !== undefined ? { min: String(range.min) } : {}), - ...(range?.max !== undefined ? { max: String(range.max) } : {}), - ...(range ? { step: "any" } : {}), - placeholder: auto === null ? "" : String(auto), - }, - }) - ) as HTMLInputElement | HTMLSelectElement; - const error = el("span", { className: "m02-inputval__error" }); - const box = el("input", { - attrs: { - type: "checkbox", - title: VALUE_TEXT.confirmBox, - "aria-label": VALUE_TEXT.confirmBox, - }, - }) as HTMLInputElement; - - if (control instanceof HTMLSelectElement) { - control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`, - }), - ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), - ); - } - control.value = isEmpty(stored.값) ? "" : String(stored.값); - control.classList.toggle("is-auto", isEmpty(stored.값)); - box.checked = stored.확인; - - const outside = (raw: string): boolean => { - if (!range || raw.trim() === "") return false; - const value = Number(raw); - return ( - !Number.isFinite(value) || - (range.min !== undefined && value < range.min) || - (range.max !== undefined && value > range.max) - ); - }; - let wasBad = false; - const changed = (): void => { - const raw = control.value; - const bad = outside(raw); - if (bad !== wasBad) invalid += bad ? 1 : -1; - wasBad = bad; - control.setAttribute("aria-invalid", bad ? "true" : "false"); - error.textContent = bad - ? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) - : ""; - control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = - raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw); - // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) - put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); - box.checked = raw !== "" && !bad; - emit(); - }; - control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); - // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 - box.addEventListener("change", () => { - const now = one(logic.로직, input.이름); - if (box.checked && isEmpty(now.값) && auto !== null) { - control.value = String(auto); - control.classList.remove("is-auto"); - put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); - } else if (box.checked && isEmpty(now.값)) { - box.checked = false; // 받아들일 값이 없음 - } else { - put(logic.로직, input.이름, { ...now, 확인: box.checked }); - } - emit(); - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); - }; - - const paint = (note?: string, retry?: () => void): void => { - invalid = 0; - paintSummary(); - if (!data) { - places.textContent = ""; - if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); - return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); - } - const seats = [...seatsOf(data.link).values()]; - const area = seats.filter((seat) => seat === "면적").length; - const material = seats.filter((seat) => seat === "재료").length; - places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; - const shown = groups(); - const missing = data.list.빠진로직.length - ? [ - el("p", { - className: "m02-inputval__missing", - text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }), - }), - ] - : []; - if (!shown.length) { - body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); - return; - } - body.replaceChildren( - ...shown.map(({ logic, inputs }) => - el("section", { - className: "m02-inputval__logic", - attrs: { "data-logic": logic.로직 }, - children: [ - el("h4", { - className: "m02-inputval__logic-name", - text: logic.이름 || logic.로직, - attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, - }), - ...inputs.map((input) => - el("div", { - className: "m02-inputval__row", - attrs: { "data-input": inputKey(logic.로직, input.이름) }, - children: [ - el("span", { - className: "m02-inputval__name", - text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, - attrs: input.설명 ? { title: input.설명 } : {}, - }), - valueCell(logic, input), - ], - }), - ), - ], - }), - ), - ...missing, - ); - }; - - return { - root, - set: (next, note, retry) => { - data = next; - values = next ? structuredClone(next.values) : {}; - paint(note, retry); - }, - loading: () => shell.loading(), - getValues: () => { - // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) - const out: ValueDoc = {}; - for (const [logic, byName] of Object.entries(values)) - for (const [name, entry] of Object.entries(byName)) - if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; - return out; - }, - isValid: () => invalid === 0, - setEditLabel: (label) => { - (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; - }, - }; -} +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */ +export { + createInputsValueCard, + type InputsValueData, + type InputsValueHandle, + type InputsValueOptions, +} from "./M02_Labor_Values"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts index a6ab719f2..bbdb8fc53 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Inputs_Model.ts - * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글. + * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록. + * 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄). * 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` · * 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음. * ========================================================================== */ @@ -78,62 +79,11 @@ export function pillsOf( return out; } -const text = (value: unknown): string => - value === null || value === undefined - ? "" - : typeof value === "object" - ? JSON.stringify(value) - : String(value); - -/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ -export function valueHint(input: ItemInput): string { - if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); - const range = input.범위; - if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; - if (range && typeof range === "object") { - const r = range as Record; - const lo = r.최소 ?? r.min; - const hi = r.최대 ?? r.max; - if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; - } - return text(range); -} - -/** 자동값 글 — 없으면 빈 글 */ -export const autoText = (input: ItemInput): string => text(input.자동값); +/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */ +export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model"; /* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */ -/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ -export type ValueDoc = Record< - string, - Record ->; - -const number = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ -export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { - const raw = input.범위; - if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; - if (raw && typeof raw === "object") { - const r = raw as Record; - return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; - } - return null; -} - -/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ -export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { - return (input.고르기 ?? []).map((one) => { - const object = - typeof one === "object" && one !== null ? (one as Record) : null; - const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; - return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; - }); -} - /** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */ export function pickedGroups( list: ItemInputs, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..0944f8d6d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos @@ -230,3 +231,9 @@ def get_logic(key: str) -> dict: def get_logic_auto(key: str) -> dict: """견본 입력 — 미리 보기용 자동값.""" return _read(m01.auto, key) + + +@router.get("/logic-lib/inputs", dependencies=[Depends(verify_session)]) +def get_logic_inputs(keys: str = "") -> dict: + """일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`).""" + return _read(labor_inputs.logic_inputs, keys.split(",")) diff --git a/resources/tester/helper_m02_inputs.cjs b/resources/tester/helper_m02_inputs.cjs index 6f014a6a1..1e14e5c27 100644 --- a/resources/tester/helper_m02_inputs.cjs +++ b/resources/tester/helper_m02_inputs.cjs @@ -4,13 +4,18 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; +// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일 +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 local = (request) => + request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request); + new Function("exports", "module", "require", js)(box.exports, box, local); + return box.exports; +} +const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts")); // 같은 이름(높이)의 입력이 두 로직에 있음 const list = { diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index f5a58dfe4..e0b785b12 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -137,11 +137,14 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): def test_선택_입력_카드는_sub2_파일_한_벌(out): - """두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일).""" + """두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은 + 값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄.""" assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists() inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8") + values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8") assert "export function createInputsCard(" in inputs # M02 자리 고르기(link) - assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value) + assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value) + assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): text = (UI / name).read_text(encoding="utf-8") assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name @@ -183,6 +186,8 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_ItemSource.ts", M02 / "M02_MasterTemplete_Inputs.ts", M02 / "M02_MasterTemplete_Inputs_Model.ts", + M02 / "M02_Labor_Values.ts", + M02 / "M02_Labor_Values_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 8501c9945..f0070d654 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -44,8 +44,8 @@ def test_네_카드가_틀을_씀_수정_단추_한_모양(): assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards - inputs = _text("M02_MasterTemplete_Inputs.ts") - assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드 + inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts") + assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로) assert "shell.fail(" in inputs and "shell.loading()" in inputs assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음 drawing = _text("M02_MasterTemplete_DrawingCard.ts") diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py new file mode 100644 index 000000000..1757129cb --- /dev/null +++ b/resources/tester/test_m02_labor_values.py @@ -0,0 +1,101 @@ +"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델). + +못박는 것 + - 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」. + - 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`). + - 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 · + 모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만). +화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router + +ROOT = Path(__file__).resolve().parents[2] +A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비 + + +@pytest.fixture() +def client() -> TestClient: + app = FastAPI() + app.include_router(combos_router.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"} + return TestClient(app) + + +def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None: + res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"}) + assert res.status_code == 200, res.text + got = res.json() + assert [x["로직"] for x in got["로직"]] == [B, A] + assert got["빠진로직"] == ["GF999999"] + one = got["로직"][1] + assert one["이름"] and one["결과단위"] + assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"]) + # 옛 선택 입력 목록과 같은 몸 + assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"] + + +def test_빈_키는_빈_목록(client: TestClient) -> None: + got = client.get("/api/m02/logic-lib/inputs").json() + assert got == {"로직": [], "빠진로직": []} + + +SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; +const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 }; +const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] }; +const size = { 이름: "규격", 고르기: [25, 40] }; +const area = { 이름: "지역" }; +const inputs = [height, kind, size, area]; +console.log(JSON.stringify({ + stored: [ + m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"), + m.storedOf(size, "40"), m.storedOf(area, "서울"), + ], + outside: [ + m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""), + m.isOutside(height, "abc"), m.isOutside(area, "9"), + ], + known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }), + line: m.valueLine(inputs, { 높이: 2, 규격: 40 }), + lineEmpty: m.valueLine(inputs, {}), +})); +""" + + +def test_값_셈() -> None: + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + out = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + assert got["stored"] == [None, 2.5, "막돌", 40, "서울"] + assert got["outside"] == [True, False, False, True, False] + assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]} + assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음 + assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님 From 80a635d3b9c62017266ec9b6a78c06444089aaa2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:30:41 +0900 Subject: [PATCH 11/36] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EC=84=A0=20=C2=B7=20=ED=95=98=EC=9C=84=20=E3=80=8C?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=A0=84=EC=B2=B4=20=ED=95=AD=EB=AA=A9=E3=80=8D=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20onAll=20=EC=86=90=EC=9E=A1=EC=9D=B4(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층 단추 · 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출(재료산출 · 자재 조합은 sub6 몫으로 그대로) - 하위마다 맨 위 「전체 항목」 — 누르면 SideOptions.onAll(kind)(화면은 39-5) · 양식 · 다른 보기가 열리면 고름 지움 - 이름 「일위대가」(하위 제목 · 대시보드 카드 글) · 해시 open=일위대가 = 일위대가 하위 펼침 + 전체 항목 - 일위대가 열 줄은 SU 번호(39-1a) 뒤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_UI_Side.ts | 83 +++++++++++++++---- .../M02_MasterTemplete_UI_Style.css | 7 ++ resources/tester/test_m02_labor_tree.py | 16 ++++ .../tester/test_m02_structure_options.py | 16 ++-- resources/tester/test_m02_structure_panel.py | 21 +++-- ui_template/ui_template_locale_m2.ts | 4 +- 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f2133c829..691426e65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 · - * 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`. + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 · + * 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기). + * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`. @@ -46,13 +47,22 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */ -type ColumnKind = "structure" | "area" | "material"; -const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [ - { kind: "structure", key: "도번", label: () => L("M02_KindStructure") }, - { kind: "area", key: "면적번", label: () => L("M02_KindArea") }, - { kind: "material", key: "재료번", label: () => L("M02_KindMaterial") }, +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +type ColumnKind = "structure" | "labor" | "area" | "material"; +const COLUMN_KINDS: { + kind: ColumnKind; + key: keyof StructureNumbers | null; + label: () => string; + all: boolean; +}[] = [ + { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, + { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, + { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; +/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */ +export type AllKind = "structure" | "labor" | "area"; /** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */ const STUB_DRAWING = { format: 6, @@ -75,6 +85,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; + /** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */ + onAll?: (kind: AllKind) => void; } export interface SideHandle { @@ -102,6 +114,8 @@ export function buildSide(opt: SideOptions): SideHandle { let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; let numbers: Record = {}; + /** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */ + let allPicked: AllKind | null = null; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => @@ -133,7 +147,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), paint()), + onOpen: () => (options.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -145,7 +159,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), paint()), + onOpen: () => (matset.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -154,12 +168,14 @@ export function buildSide(opt: SideOptions): SideHandle { }); return sec; }; - // 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · + const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])); + // 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · // 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6) const summary = section( L("M02_KindTable"), el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }), - ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), + el("hr", { className: "m02-side__rule" }), + ...columnSections, matsetSection, ); summary.classList.remove("is-collapsed"); @@ -219,9 +235,25 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { + /** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */ + function allRow(kind: AllKind): HTMLElement { + const b = el("button", { + className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`, + text: L("M02_ItemsAll"), + attrs: { type: "button", "data-all": kind }, + }); + b.addEventListener("click", () => void selectAll(kind)); + return b; + } + + /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { + const { key } = k; + const head = k.all ? [allRow(k.kind as AllKind)] : []; + if (key === null) return body.replaceChildren(...head); + const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 body.replaceChildren( + ...head, ...(columns.length ? columns.map((col) => { const num = numbers[col.id]?.[key] || L("M02_StructureNone"); @@ -236,8 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle { : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } - const paintColumns = (): void => - COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); + const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k)); /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { @@ -248,6 +279,7 @@ export function buildSide(opt: SideOptions): SideHandle { /** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음 * (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */ function paint(): void { + if (opt.getOpen()) allPicked = null; paintList(); paintColumns(); paintButtons(); @@ -255,13 +287,22 @@ export function buildSide(opt: SideOptions): SideHandle { if (opt.getOpen()) options.clear(); } + async function selectAll(kind: AllKind): Promise { + if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; + matset.clear(); + options.clear(); + allPicked = kind; + opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 + paint(); + } + // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { + async function selectColumn(col: StructureColumn, kind: Kind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); @@ -377,7 +418,13 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 + // 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」 + if (target === "일위대가") { + columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove( + "is-collapsed", + ); + return void selectAll("labor"); + } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index c4c51d1ca..aa14b148e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -15,6 +15,13 @@ margin-left: var(--spacing-8); } +/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */ +.m02-side__rule { + margin: var(--spacing-8) 0; + border: 0; + border-top: 1px solid var(--color-border); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 755dac40b..0776b560f 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -29,6 +29,22 @@ def test_나무_부품에_일위대가_전용_칸_없음(): assert gone not in panel, gone +def test_하위_차례_전체_항목_이름(): + side = _text("M02_MasterTemplete_UI_Side.ts") + kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] + order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] + assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 + assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 + assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side + assert 'return void selectAll("labor");' in side # 해시 open=일위대가 + locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") + assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale + assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale + css = _text("M02_MasterTemplete_UI_Style.css") + assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css + + def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 6df8d180b..5d1a98e5e 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,17 +120,11 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식 - order = [ - "...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]" - " : []),", - " summary,", - " ...(extras ? [extras.옵션, extras.도면] : []),", - ] - assert "\n".join(order) in side - # 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움 - assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side - assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 + assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side + # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 + assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index bb9b3341f..b10a492d8 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -154,9 +154,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만 - # 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) + # 구조물 도면 · 일위대가 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) · + # 층 단추 · 나무와 하위 사이 수평선(39-2) summary = side.split("const summary = section(")[1].split(");")[0] - assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary + assert ( + summary.index("opt.tree") + < summary.index("m02-side__rule") + < summary.index("...columnSections") + ) + assert "matsetSection" in summary + assert "const columnSections = COLUMN_KINDS.map(" in side assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) @@ -184,7 +191,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" side = _text(SIDE) assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 - assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side) + assert re.search( + r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*" + r"paintList\(\);\s*paintColumns\(\);", + side, + ) # 양식이 열리면 「전체 항목」 고름도 지움(39-2) for fn in ("async function select(", "async function selectColumn("): body = side.split(fn)[1].split("\n }\n")[0] assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 @@ -192,8 +203,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): # 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움 assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN)) assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1] - # 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리) - assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts")) + # 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움) + assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts") def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1058adaa6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,7 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_CardUnitCombo: ["일위대가 조합", "Unit cost combos"], + B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"], B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], @@ -172,4 +172,6 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_KindLabor: ["일위대가", "Unit costs"], + M02_ItemsAll: ["전체 항목", "All items"], } as const; From eb132f13d0d86d8add08b58e0c90b9228a2d2567 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:31:11 +0900 Subject: [PATCH 12/36] =?UTF-8?q?feat(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20ItemPick=20?= =?UTF-8?q?=E2=80=94=20=ED=83=AD(=EB=A7=88=EC=8A=A4=ED=84=B0=20=C2=B7=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=C2=B7=20=EA=B0=9C=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EB=9E=8C=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=EB=AC=B8=EC=84=9C)=20+=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=B9=B8=20=EC=95=88=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BD=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H3 — 탭 목록 · 기본 탭 = readableSources(39-6 E5) · 다른 사람 = 사람 고르기 하나 · 실무문서 = 마스터 일위대가 조합(일위대가 고를 때). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 15 ++ .../M02_MasterTemplete_ItemPick.ts | 178 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemPick.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css new file mode 100644 index 000000000..70f57c665 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -0,0 +1,15 @@ +/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */ +.m02-itempick { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick .m01-master__grid-wrap { + max-height: min(60vh, calc(100vh - 300px)); +} + +.ui-modal.m02-itempick__dialog { + width: min(960px, 94vw); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts new file mode 100644 index 000000000..dc1fbc122 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemPick.ts + * 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`). + * 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀. + * 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈 + * (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로. + * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). + * ========================================================================== */ + +import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + buildItemList, + ITEM_COLS, + loadItems, + type Col, + type ItemRow, + type ListKind, +} from "./M02_MasterTemplete_ItemList"; +import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; +import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; +import "./M02_MasterTemplete_TreePanel.css"; +import "./M02_MasterTemplete_ItemPick.css"; + +const TEXT = { + close: "닫기", + person: "사람", + noPeople: "다른 사람이 없음", + failed: "목록을 못 받음 — {value}", +}; + +/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ +export interface From { + layer: Layer; + owner: string | null; + label: string; + practice: boolean; +} + +export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary }; + +/** 고른 것 이름 — 확인 글에 씀 */ +export const pickedName = (p: Picked): string => + "row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`; + +const PRACTICE_COLS: Col[] = [ + { head: "키", get: (c) => c.키 }, + { head: "이름", get: (c) => c.이름 }, + { head: "구분", get: (c) => c.구분 ?? "" }, + { head: "상세구분", get: (c) => c.상세구분 ?? "" }, + { head: "단위", get: (c) => c.단위 ?? "" }, + { head: "담은 로직 수", get: (c) => String(c.count) }, +]; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface PickerOptions { + user: SessionUser | null; + /** 볼 회사(`viewCompany`) */ + company: string | null; + /** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */ + kind: ListKind; + /** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */ + skip?: (from: From, row: ItemRow) => boolean; + onPick: (picked: Picked) => void; +} + +/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */ +export function buildPicker(opt: PickerOptions): { root: HTMLElement } { + const { tabs, first } = readableSources(opt.user, opt.company, { + practice: opt.kind === "labor", + }); + const list = buildItemList(); + const people = createSelectField({ options: [], compact: true, label: TEXT.person }); + people.root.hidden = true; + let owners: { owner: string; name: string }[] | null = null; + let seq = 0; + + const buttons = tabs.map((tab) => { + const b = el("button", { + className: "m02-layers__btn", + text: tab.label, + attrs: { type: "button", "data-source": tab.id }, + }); + b.addEventListener("click", () => void show(tab)); + return b; + }); + + async function show(tab: Source): Promise { + const mine = ++seq; + buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id)); + people.root.hidden = tab.id !== "people"; + list.message(); + try { + if (tab.id === "practice") { + const combos = await fetchCombos(); + if (mine !== seq) return; + const from = { layer: tab.layer, owner: null, label: tab.label, practice: true }; + return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo })); + } + let owner = tab.owner; + let label = tab.label; + if (tab.id === "people") { + owners ??= await fetchOwners("personal", opt.company); + if (mine !== seq) return; + if (!owners.length) return list.message(TEXT.noPeople); + if (!people.select.value) { + people.setOptions( + owners.map((o) => ({ value: o.owner, text: o.name })), + owners[0].owner, + ); + } + owner = people.select.value; + label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + } + const rows = await loadItems(tab.layer, owner); + if (mine !== seq) return; + const from = { layer: tab.layer, owner, label, practice: false }; + list.show( + rows.filter((row) => !opt.skip?.(from, row)), + ITEM_COLS[opt.kind], + (row) => opt.onPick({ from, row }), + ); + } catch (error) { + if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); + } + } + + people.select.addEventListener("change", () => { + const tab = tabs.find((t) => t.id === "people"); + if (tab) void show(tab); + }); + const start = tabs.find((t) => t.id === first) ?? tabs[0]; + if (start) void show(start); + return { + root: el("div", { + className: "m02-itempick", + children: [ + el("div", { className: "m02-layers__row", children: [...buttons, people.root] }), + list.root, + ], + }), + }; +} + +/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +export function pickFrom( + opt: Omit & { title: string; ask?: (picked: Picked) => string }, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Picked | null): void => { + if (!done) resolve(value); + done = true; + }; + openModal({ + title: opt.title, + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-itempick__dialog", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const picker = buildPicker({ + ...opt, + onPick: (picked) => + void (async () => { + if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return; + finish(picked); + close(); + })(), + }); + body.append(picker.root); + }, + }); + }); +} From af92f0553e7c58009e3b449e88745dc64763791c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:34:10 +0900 Subject: [PATCH 13/36] =?UTF-8?q?feat(M02):=20=E3=80=8C=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=E3=80=8D=20=ED=99=94=EB=A9=B4=20=E2=80=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=8F=84=EB=A9=B4=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=95=98=EC=9C=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=83=81=EC=84=B8=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H2 — 좌측 onAll(39-2) 이음 · 제목 「하위 · 전체 항목」 · 목록 = ItemList(M01 꼴). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 17 ++++++++++++ .../M02_MasterTemplete_UI_Page.ts | 26 +++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 363c48e5c..4d015ca6b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -35,6 +35,7 @@ const TEXT = { none: "항목이 없음", noMatch: "맞는 항목이 없음", loading: "불러오는 중", + failed: "목록을 못 받음", }; const SIZE = 30; @@ -111,6 +112,22 @@ export function pageOf( export const loadItems = async (layer: Layer, owner: string | null): Promise => (await fetchTree(layer, owner)).items as ItemRow[]; +/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */ +export function buildAllItems( + kind: ListKind, + layer: Layer, + owner: string | null, + onOpen: (row: ItemRow) => void, +): HTMLElement { + const list = buildItemList(); + loadItems(layer, owner).then( + (rows) => list.show(rows, ITEM_COLS[kind], onOpen), + (error: unknown) => + list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`), + ); + return list.root; +} + export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index a19bc482a..9b792a9ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, @@ -41,9 +41,17 @@ import { seesOthers, } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; -import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; +import { buildAllItems } from "./M02_MasterTemplete_ItemList"; import "./M02_MasterTemplete_UI_Style.css"; +/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ +const ALL_TITLE = { + structure: "M02_KindStructure", + labor: "M02_KindLabor", + area: "M02_KindArea", +} as const satisfies Record; + export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const entry = entryFromHash(); @@ -155,6 +163,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], + // 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`) + onAll: (kind) => + void main.showView( + `${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`, + buildAllItems(kind, layer, owner, (row) => { + void main.open({ + layer, + projectId: null, + kind: kind as Kind, + name: row.열, + label: row.이름, + }); + }), + ), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); From 516719d50d6a5b7cca9b83acfd8b46865f86aa5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:36:44 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat(M02):=20=EC=B8=B5=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=E3=80=8C=ED=9A=8C=EC=82=AC=20=EB=A7=A1=EC=9D=8C?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=A7=A1=EC=9D=80=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=20=C2=B7=20=EA=B7=B8=20=ED=9A=8C=EC=82=AC=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=20=EC=B8=B5=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EB=82=A8=EC=9D=98?= =?UTF-8?q?=20=EC=B8=B5=20=EC=B2=AB=20=EC=93=B0=EA=B8=B0=20=EC=86=8C?= =?UTF-8?q?=EC=86=8D=20=ED=99=95=EC=9D=B8=20=EB=92=A4=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B8=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EB=A7=8C=20=C2=B7=20=EC=82=AC=EB=9E=8C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=84=EB=B6=80=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(39-6=20E1~E4)?= 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_013rJwJMVL4EE31y77TB11AP --- M02_MasterTemplete/M02_Layer_Access.py | 39 +++++----- .../M02_MasterTemplete_Router_Combos.py | 14 +++- .../M02_MasterTemplete_Router_Items.py | 55 +++++++++++++- .../M02_MasterTemplete_Router_Layers.py | 39 +++++++--- resources/tester/test_m02_combo_layers.py | 20 ++++++ resources/tester/test_m02_layer_access.py | 71 ++++++++++++++++--- resources/tester/test_m02_template_layers.py | 34 +++++++-- 7 files changed, 227 insertions(+), 45 deletions(-) diff --git a/M02_MasterTemplete/M02_Layer_Access.py b/M02_MasterTemplete/M02_Layer_Access.py index fe1b71430..2a0cc5dc8 100644 --- a/M02_MasterTemplete/M02_Layer_Access.py +++ b/M02_MasterTemplete/M02_Layer_Access.py @@ -1,13 +1,12 @@ """M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀 (PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`. +「회사 C 를 맡음」(`manages` · 39-6) = SYSTEM_ADMIN · 또는 C 의 회사 관리자(ADMIN · 마스터). system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN - company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) · - 남의 회사 = SYSTEM_ADMIN 읽기만 - personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 · - 다른 회사 = SYSTEM_ADMIN 읽기만 -owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) · -읽기로 남의 층에 뼈대를 안 만듦. + company {C} 읽기 = C 사람 · SYSTEM_ADMIN · 수정 = C 를 맡은 사람 + personal {C}/{U} 본인 = 읽기 · 수정 · 남 = C 를 맡은 사람만 읽기 · 수정(일반 = 403) +owner 글 없음 = 자기 층 · 남의 층 읽기는 그 층 폴더가 있을 때만(없으면 404) · +읽기로 남의 층에 뼈대를 안 만듦(쓰기는 부르는 쪽이 소속을 본 뒤 `folder(…, create=True)`). """ from __future__ import annotations @@ -33,6 +32,14 @@ def is_company_admin(session: dict[str, Any]) -> bool: return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) +def manages(session: dict[str, Any], company: Any) -> bool: + """회사 `company` 를 맡음 — 그 회사 층 · 그 회사 모든 개인 층을 보고 고침.""" + if is_system_admin(session): + return True + own = session.get("company_id") + return own is not None and str(own) == str(company) and is_company_admin(session) + + @dataclass(frozen=True) class Grant: """층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨).""" @@ -63,15 +70,14 @@ def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Gran raise HTTPException(status_code=409, detail=NO_COMPANY) if not _SHAPE[layer].match(who): raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」") + company = who.split("/")[0] if who == own: - return Grant(layer, who, layer == "personal" or is_company_admin(session), True) - same_company = who.split("/")[0] == str(session.get("company_id")) - colleague = layer == "personal" and same_company and is_company_admin(session) - if not (is_system_admin(session) or colleague): + return Grant(layer, who, layer == "personal" or manages(session, company), True) + if not manages(session, company): raise HTTPException( status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음" ) - return Grant(layer, who, False, False) + return Grant(layer, who, True, False) def require( @@ -82,16 +88,15 @@ def require( if write and not got.write: if layer == "system": detail = "마스터 템플릿은 시스템 관리자만 고침." - elif got.mine: - detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." else: - detail = "남의 층은 읽기만." + detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." raise HTTPException(status_code=403, detail=detail) return got -def folder(got: Grant) -> Path: - """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦).""" +def folder(got: Grant, create: bool = False) -> Path: + """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함 · `create` = 쓰기(부르는 쪽이 소속을 + 본 뒤 뼈대를 만듦).""" if got.owner is None: raise HTTPException(status_code=409, detail=NO_COMPANY) try: @@ -102,6 +107,6 @@ def folder(got: Grant) -> Path: path = layers.personal_dir(company, user) except ValueError as error: raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error - if not got.mine and not path.is_dir(): + if not (got.mine or create) and not path.is_dir(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return path diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..8495ad84c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -7,13 +7,14 @@ 저장소 = `M02_MasterTemplete_Combo_Store`. 담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. +39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). """ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from common_util.common_util_auth import verify_session @@ -23,7 +24,16 @@ from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"]) + +def _door(request: Request, session: Session = Depends(verify_session)) -> None: + """일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + if "/logic-lib/" not in request.url.path and not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.") + + +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)] +) class ComboBody(BaseModel): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 4321aee0f..0a40524d3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -10,13 +10,17 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) from __future__ import annotations +import logging from pathlib import Path from typing import Any, Literal +import anyio.from_thread from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel +from common_util.common_util_audit import record_audit from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool from M02_MasterTemplete import M02_Item_Paste as paste_store from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch @@ -32,6 +36,7 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import ( UseComboBody, ) +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) Layer = Literal["system", "company", "personal"] @@ -74,14 +79,60 @@ class CopyItemBody(BaseModel): key: str +async def _member(owner: str) -> bool: + """층 주인이 있나 — 회사 `{C}` = 그 회사 · 개인 `{C}/{U}` = U 가 C 소속.""" + company, _, user = owner.partition("/") + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + if user: + await cursor.execute( + "SELECT 1 FROM users WHERE id = %s AND company_id = %s AND deleted_at IS NULL", + (int(user), int(company)), + ) + else: + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (int(company),) + ) + return await cursor.fetchone() is not None + + +async def _note(session: Session, got: access.Grant) -> None: + """남의 층 쓰기 한 줄(39-6 · 시스템 로그) — 기록이 못 돼도 쓰기는 막지 않음.""" + try: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await record_audit( + cursor, + actor_id=int(session["user_id"]), + action="M02_OTHER_LAYER_WRITE", + resource_type=f"m02_{got.layer}", + resource_ref=got.owner, + ) + await connection.commit() + except Exception: + logger.exception("M02 남의 층 쓰기 기록 실패: %s %s", got.layer, got.owner) + + +async def others_write(session: Session, got: access.Grant, folder: Path) -> None: + """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 쓸 때마다 시스템 로그.""" + if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): + if not await _member(str(got.owner)): + raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님") + items_store.ensure(folder) + await _note(session, got) + + def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: - """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦.""" + """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦 · + 남의 층 쓰기(맡은 회사 · 39-6)는 `others_write`.""" got = access.require(session, layer, owner, write=write) if layer == "system": return store.FOLDER - folder = access.folder(got) + folder = access.folder(got, create=write) if got.mine: items_store.ensure(folder) + elif write: + anyio.from_thread.run(others_write, session, got, folder) elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return folder diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index a9d65b1ab..f3097f50b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers @@ -164,8 +165,10 @@ def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int: async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None: + """남의 개인 양식 = 그 회사를 맡은 사람만(39-6 · 층 길과 한 규칙) · 그 회사 사람이어야 함.""" if user_id == session.get("user_id"): return + access.grant(session, "personal", f"{company_id}/{user_id}") if await _user_company(user_id) != company_id: raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.") @@ -192,7 +195,12 @@ async def _layer_dir( who = session["user_id"] if user_id is None else user_id owner = str(company_id) if layer == "company" else f"{company_id}/{who}" got = access.require(session, layer, owner, write=write) - return _ensured(access.folder(got)) if got.mine else access.folder(got) + folder = access.folder(got, create=write) + if got.mine: + return _ensured(folder) + if write: # 남의 층 쓰기(맡은 회사 · 39-6) — 구조물 길과 한 벌 + await items_router.others_write(session, got, folder) + return folder def _ensured(folder: Path) -> Path: @@ -214,9 +222,9 @@ async def list_owners( company: int | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> list[dict[str, Any]]: - """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌). - 회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 · - 회사 관리자는 자기 회사만).""" + """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]`. + 회사 = SYSTEM_ADMIN 만 · 층 자리가 있는 것만(자기 회사는 늘) · + 개인 = 그 회사를 맡은 사람(39-6)만 · 회사 사람 전부(나 맨 위 · 집계표 없으면 `비어있음`).""" if layer == "company": if not access.is_system_admin(session): raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.") @@ -225,12 +233,16 @@ async def list_owners( target = session.get("company_id") if company is None else company if target is None: raise HTTPException(status_code=409, detail=access.NO_COMPANY) - mine = str(target) == str(session.get("company_id")) - if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))): + if not access.manages(session, target): raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.") me = session.get("user_id") users = await _company_users(int(target)) - rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me] + users.sort(key=lambda u: u["user_id"] != me) # 나 맨 위 + return [ + {"owner": owner, "name": u["name"], "비어있음": _empty(owner)} + for u in users + for owner in [f"{target}/{u['user_id']}"] + ] found = [] for owner, name in rows: got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer)) @@ -242,6 +254,13 @@ async def list_owners( return found +def _empty(owner: str) -> bool: + """개인 층에 집계표가 없음 — 사람 나무 「비어 있음」(폴더를 안 만듦).""" + company, user = owner.split("/") + table = layers.personal_dir(company, user) / "table" / f"{store.STRUCTURE_TABLE}.json" + return not table.is_file() + + @router.get("/layers/{layer}/templates") async def list_layer( layer: Layer, @@ -519,7 +538,11 @@ async def list_sources( ) return rows - users = await _company_users(company_id) + users = [ # 남의 개인 = 그 회사를 맡은 사람만(39-6) + u + for u in await _company_users(company_id) + if u["user_id"] == session.get("user_id") or access.manages(session, company_id) + ] projects = await _company_projects(company_id) return { "system": await asyncio.to_thread(layers.list_templates, layers.system_dir()), diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 7796b80d3..9bcbfb596 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -54,9 +54,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: app.include_router(items_router.router) app.include_router(combos_router.router) app.dependency_overrides[verify_session] = lambda: session + # 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙 + app.dependency_overrides[combos_router._door] = lambda: None return {"client": TestClient(app), "session": session, "storage": storage} +def test_조합_길은_시스템_관리자만(world): + """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + world["client"].app.dependency_overrides.pop(combos_router._door) + for role in ("USER", "ADMIN"): + client = _as(world, role) + assert client.get(URL.format("system")).status_code == 403 + assert client.get("/api/m02/combos/all").status_code == 403 + assert client.get("/api/m02/combos/key/UA000001").status_code == 403 + assert _new(client, "personal", _combo("내 것"), 403) is not None + assert client.get("/api/m02/logic-lib/logics").status_code == 200 + world["session"]["is_master"] = True + assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도 + world["session"]["is_master"] = False + admin = _as(world, "SYSTEM_ADMIN") + assert admin.get(URL.format("system")).status_code == 200 + assert admin.get(URL.format("company")).status_code == 200 + + def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: world["session"].update(role=role, user_id=user) return world["client"] diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py index 42e5189b3..f89e29ea9 100644 --- a/resources/tester/test_m02_layer_access.py +++ b/resources/tester/test_m02_layer_access.py @@ -16,6 +16,7 @@ from fastapi import HTTPException from config import config_system from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers @@ -55,16 +56,16 @@ def test_권한_표(): } assert got == { "일반": ["읽기", "읽기", "403", "수정", "403", "403"], - "회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"], - "마스터": ["읽기", "수정", "403", "수정", "읽기", "403"], - "시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"], + "회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6) + "마스터": ["읽기", "수정", "403", "수정", "수정", "403"], + "시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음 } # 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409 assert access.grant(USER, "personal", "7/42").write is True assert _answer(SYSADMIN, "personal", "9") == "400" assert _answer({**USER, "company_id": None}, "company", None) == "409" with pytest.raises(HTTPException) as caught: - access.require(ADMIN, "personal", "7/43", write=True) + access.require(USER, "company", None, write=True) assert caught.value.status_code == 403 @@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: monkeypatch.setattr(layers_router, "_company_users", company_users) monkeypatch.setattr(layers_router, "_companies", companies) + notes: list[tuple[int, str, str | None]] = [] + + async def member(owner: str) -> bool: + company, _, user = owner.partition("/") + return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9) + + async def note(session: dict[str, Any], got: Any) -> None: + notes.append((session["user_id"], got.layer, got.owner)) + + monkeypatch.setattr(items_router, "_member", member) + monkeypatch.setattr(items_router, "_note", note) session: dict[str, Any] = dict(USER) - return {"client": _app(session), "session": session, "storage": storage} + return {"client": _app(session), "session": session, "storage": storage, "notes": notes} def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): @@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): assert client.get(tree).status_code == 200 assert client.get(table).status_code == 200 # 양식 길도 같은 규칙 body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]} - assert client.post(tree, json=body).status_code == 403 # 읽기만 + assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6) + assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄 assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200 session.update(USER) assert client.get(tree).status_code == 403 assert client.get(table).status_code == 403 + assert ( + client.post(tree, json={**body, "이름": "몰래"}).status_code == 403 + ) # 일반 = 남 개인 막힘 session.update(SYSADMIN) assert client.get(tree).status_code == 200 - assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만 + body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]} + assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나 + + +def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]): + """39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 · + 읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP).""" + client, session, storage = world["client"], world["session"], world["storage"] + session.update(ADMIN) + add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""} + assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404 + assert not (storage / "7/44").exists() + assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403 + assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦 + assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403 + first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add) + assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침 + assert (storage / "7/44/templates/table/구조물집계표.json").is_file() + add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"] + assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200 + session.update(SYSADMIN) + assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403 + assert not (storage / "5").exists() # 없는 회사 + add["판"] = "" + assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409 + assert (storage / "9/templates/table/구조물집계표.json").is_file() + assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"] def test_남의_층_목록(world: dict[str, Any]): @@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]): people = "/api/m02/layers/personal/owners" assert client.get(people).status_code == 403 # 일반 사용자 assert client.get("/api/m02/layers/company/owners").status_code == 403 + (storage / "7/43/templates/table").mkdir(parents=True) + (storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8") session.update(ADMIN) - assert client.get(people).json() == [ - {"owner": "7/43", "name": "u43"} - ] # 본인 · 44(자리 없음) 뺌 + assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시 + {"owner": "7/42", "name": "u42", "비어있음": True}, + {"owner": "7/43", "name": "u43", "비어있음": False}, + {"owner": "7/44", "name": "u44", "비어있음": True}, + ] + assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦 assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람 session.update(SYSADMIN) assert client.get("/api/m02/layers/company/owners").json() == [ {"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도 {"owner": "9", "name": "c9"}, ] - assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}] + assert client.get(f"{people}?company=9").json() == [ + {"owner": "9/90", "name": "u90", "비어있음": True} + ] diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 92c9f1bc6..4724c5ab2 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -13,6 +13,7 @@ from fastapi.testclient import TestClient from common_util import common_util_storage from common_util.common_util_auth import verify_session from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.helper_m02_master_sample import sample_table @@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company" -def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None: +def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴( + world: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: client, session = world["client"], world["session"] + notes: list[str] = [] + + async def note(_session: dict[str, Any], got: Any) -> None: + notes.append(got.owner) + + monkeypatch.setattr(items_router, "_note", note) body = {"to": "personal", "종류": "table", "이름": "구조물집계표"} assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200 mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}" assert client.get(mine).status_code == 200 session.update(user_id=43) theirs = mine + "&user_id=42" - # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만 + # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6) assert client.get(theirs).status_code == 403 session["role"] = "ADMIN" assert client.get(theirs).status_code == 200 @@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42", json={"판": None, "문서": {"열": []}}, ) - assert put.status_code == 403 - assert not (world["storage"] / "7/42/templates/table/내표.json").exists() + assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그 + assert (world["storage"] / "7/42/templates/table/내표.json").is_file() + assert notes == ["7/42"] session["role"] = "USER" # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 put = client.put( @@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor got = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} ) - assert got.status_code == 200 + assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6) + session["role"] = "ADMIN" + got = client.post( + f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} + ) + assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴 assert client.get(mine.replace(P1, P_OTHER)).status_code == 403 far = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90} @@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str sources = client.get(f"/api/m02/projects/{P1}/sources").json() assert [row["project_id"] for row in sources["project"]] == [P2] assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"} + for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6) + layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []}) + assert [row["user_id"] for row in sources_of(client)] == [42] + world["session"]["role"] = "ADMIN" + assert [row["user_id"] for row in sources_of(client)] == [42, 43] + + +def sources_of(client: TestClient) -> list[dict[str, Any]]: + return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"] def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None: From 0e28b5be4abfdc76b457347694a5ec3fa40a00e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:36:51 +0900 Subject: [PATCH 15/36] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=E3=80=8D=20?= =?UTF-8?q?=E2=80=94=20=EC=98=9B=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=83=81=EC=84=B8=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=ED=86=B5=EC=A7=B8=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_MasterTemplete_Practice.ts — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 - [통째 지우기] = 물음 두 번 → DELETE /api/m02/practice(시스템 관리자 문 is_system_admin · 판 · 쓰는 곳 0 확인 · 줄을 비움) - Combo_Store.drop_practice · 시험 test_m02_practice.py(임시 사본) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Combo_Store.py | 15 ++ .../M02_MasterTemplete_Practice.ts | 224 ++++++++++++++++++ .../M02_MasterTemplete_Router_Combos.py | 9 + .../M02_MasterTemplete_UI_Side.ts | 22 +- .../M02_MasterTemplete_UI_Style.css | 40 ++++ resources/tester/test_m02_practice.py | 59 +++++ .../tester/test_m02_structure_options.py | 13 +- ui_template/ui_template_locale_m2.ts | 26 ++ 8 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Practice.ts create mode 100644 resources/tester/test_m02_practice.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 52a846549..409fb154c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -289,6 +289,21 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]: return {"키": key, "판": _save(doc, version), "combo": fixed} +def drop_practice(version: str) -> dict[str, Any]: + """실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만.""" + if heads()[-1] != MASTER: + raise store.StoreError(400, "실무문서는 마스터 층에만 있음") + doc, have = _load() + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + keys = [str(r.get("키")) for r in doc.get("줄") or []] + _refuse_used(keys) + if keys: + _m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have) + return {**listing(), "지운조합": keys} + + def delete(key: str, version: str) -> dict[str, Any]: """지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음).""" if heads()[-1] == MASTER: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Practice.ts b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts new file mode 100644 index 000000000..f3b72ceac --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * M02_MasterTemplete_Practice.ts + * 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) — + * 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) · + * [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인). + * 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; +import { + fetchCombo, + fetchComboList, + type ComboList, + type ComboOne, +} from "./M02_MasterTemplete_Labor_Api"; + +export interface PracticeSectionOptions { + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface PracticeSection { + title: string; + body: HTMLElement; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + clear: () => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */ +function reasonOf(detail: unknown, status: number): string { + if (typeof detail === "string") return detail; + const d = detail as { errors?: string[]; stale?: unknown } | null; + if (d?.errors?.length) return d.errors.join(" · "); + return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`; +} + +/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */ +async function dropPractice(판: string): Promise { + const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, { + method: "DELETE", + credentials: "include", + }); + if (res.ok) return; + const data = (await res.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(reasonOf(data.detail, res.status)); +} + +const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-practice__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +const cells = (values: string[]): HTMLElement[] => + values.map((v) => el("td", { text: v, attrs: { title: v } })); + +export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection { + const row = el("button", { + className: "m02-side__item ui-sidebar-row", + text: L("M02_PracticeRow"), + attrs: { type: "button", "data-practice-row": "" }, + }); + const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); + const view = el("div", { className: "m02-practice" }); + let opened = false; + let nav = 0; // 늦게 온 응답 버림 + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + row.addEventListener("click", () => void open()); + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + opt.show(L("M02_PracticeTitle"), view); + opt.onOpen(); + mark(true); + await drawList(); + } + + async function drawList(): Promise { + const my = ++nav; + const stop = showLoading(view); + let list: ComboList; + try { + list = await fetchComboList(); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList())); + return; + } finally { + stop(); + } + if (my !== nav) return; + const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" }); + drop.disabled = !list.줄.length; + drop.addEventListener("click", () => void dropAll(list, drop)); + const rows = list.줄.map((c) => { + const tr = el("tr", { + className: "m02-practice__row", + attrs: { "data-practice-key": c.키 }, + children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]), + }); + tr.addEventListener("click", () => void drawOne(c.키)); + return tr; + }); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [ + el("span", { + className: "m02-practice__note", + text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)), + }), + drop, + ], + }), + rows.length + ? table( + [ + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeGroup"), + L("M02_PracticeUnit"), + L("M02_PracticeLogics"), + ], + rows, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeEmpty") }), + ); + } + + /** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */ + async function drawOne(key: string): Promise { + const my = ++nav; + const stop = showLoading(view); + let one: ComboOne; + try { + one = await fetchCombo(key); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key))); + return; + } finally { + stop(); + } + if (my !== nav) return; + const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" }); + back.addEventListener("click", () => void drawList()); + const c = one.combo; + const info = [ + [L("M02_PracticeKey"), c.키], + [L("M02_PracticeGroup"), c.구분 ?? ""], + [L("M02_PracticeUnit"), c.단위 ?? ""], + ].map(([k, v]) => el("span", { className: "m02-practice__note", text: `${k} ${v}` })); + const lines = one.줄.map((x) => + el("tr", { + children: cells([ + String(x.차례), + x.로직, + x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""), + x.결과단위 ?? "", + x.메모 ?? "", + ]), + }), + ); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [back, el("strong", { text: c.이름 }), ...info], + }), + ...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []), + lines.length + ? table( + [ + L("M02_PracticeOrder"), + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeUnit"), + L("M02_PracticeMemo"), + ], + lines, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeNoLogic") }), + ); + } + + /** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */ + async function dropAll(list: ComboList, button: HTMLButtonElement): Promise { + const n = String(list.줄.length); + if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete")))) + return; + if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return; + button.disabled = true; + try { + await dropPractice(list.판); + showToast(L("M02_PracticeDropped").replace("{n}", n), "success"); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + await drawList(); + } + + return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..ad8fb8ad4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -157,6 +157,15 @@ def delete_combo( return _call(_root(session, layer, owner, write=True), combos.delete, key, 판) +@router.delete("/practice") +def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict: + """실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409.""" + if not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만") + return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) + + @router.post("/layers/{layer}/combos/tree") def post_tree( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 691426e65..7d10e1875 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -41,6 +41,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; +import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -147,7 +148,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), (allPicked = null), paint()), + onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -159,7 +160,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), (allPicked = null), paint()), + onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -191,9 +192,22 @@ export function buildSide(opt: SideOptions): SideHandle { 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; + // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 + const practice = + layer === "system" && opt.isAdmin + ? buildPracticeSection({ + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (matset.clear(), options.clear(), (allPicked = null), paint()), + }) + : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], + children: [ + summary, + ...(extras ? [extras.옵션, extras.도면] : []), + ...(practice ? [section(practice.title, practice.body)] : []), + ], }); attachCollapsible(listHost); @@ -285,12 +299,14 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); + if (opt.getOpen()) practice?.clear(); } async function selectAll(kind: AllKind): Promise { if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; matset.clear(); options.clear(); + practice?.clear(); allPicked = kind; opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 paint(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index aa14b148e..0fd456c41 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -22,6 +22,46 @@ border-top: 1px solid var(--color-border); } +/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */ +.m02-practice { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-practice__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-practice__bar > button:last-child:not(:first-child) { + margin-left: auto; +} + +.m02-practice__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-practice__memo { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + +.m02-practice__row { + cursor: pointer; +} + +.m02-practice__row:hover { + background: var(--color-mist-violet); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_practice.py b/resources/tester/test_m02_practice.py new file mode 100644 index 000000000..4466a0ac5 --- /dev/null +++ b/resources/tester/test_m02_practice.py @@ -0,0 +1,59 @@ +"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기. + +못박는 것 + - `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로. + - 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만. +정본(마스터)은 읽기만 · 쓰기는 임시 사본. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +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_MasterTemplete_Combo_Store as combos +from resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _drop(client, 판: str, want: int) -> dict: + res = client.delete("/api/m02/practice", params={"판": 판}) + assert res.status_code == want, res.text + return res.json() + + +def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811 + admin = _as(world, "SYSTEM_ADMIN") + before = _list(admin, "system") + assert before["줄"] # 사본에 옛 조합이 있음 + for role in ("USER", "ADMIN"): + _drop(_as(world, role), before["판"], 403) + admin = _as(world, "SYSTEM_ADMIN") + _drop(admin, "옛판", 409) + used = before["줄"][0]["키"] + monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else []) + got = _drop(admin, before["판"], 409) + assert "항목 하나" in json.dumps(got, ensure_ascii=False) + assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움 + monkeypatch.setattr(combo_store, "combo_users", lambda k: []) + got = _drop(admin, before["판"], 200) + assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"]) + doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) + assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == [] + again = _list(admin, "system") + assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로 + + +def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만(): + side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8") + assert 'layer === "system" && opt.isAdmin' in side + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래 + view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8") + assert view.count("showConfirmDialog(") == 2 + assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 5d1a98e5e..c8f27428d 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,11 +120,14 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 - assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side - # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 - assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side - assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서 + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert ( + order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice") + ) + # 자재 조합을 열면 이 줄 · 실무문서 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · … 지움 + assert "onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 1058adaa6..737e9f4d2 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -174,4 +174,30 @@ export const ui_locales_m2 = { M02_TreeDone: ["나무를 고침", "Tree updated"], M02_KindLabor: ["일위대가", "Unit costs"], M02_ItemsAll: ["전체 항목", "All items"], + M02_PracticeTitle: ["실무문서", "Practice documents"], + M02_PracticeRow: ["실무문서 목록", "Practice document list"], + M02_PracticeCount: [ + "옛 마스터 일위대가 조합 {n}개 — 보기 · 복사 원본만(고칠 수 없음)", + "{n} old master unit-cost combos — view and copy source only", + ], + M02_PracticeEmpty: ["실무문서 없음", "No practice documents"], + M02_PracticeKey: ["키", "Key"], + M02_PracticeGroup: ["구분", "Group"], + M02_PracticeUnit: ["단위", "Unit"], + M02_PracticeLogics: ["로직 수", "Logics"], + M02_PracticeOrder: ["차례", "No."], + M02_PracticeMemo: ["메모", "Memo"], + M02_PracticeNoLogic: ["담은 로직 없음", "No logics"], + M02_PracticeBack: ["목록으로", "Back to list"], + M02_PracticeDrop: ["통째 지우기", "Delete all"], + M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], + M02_PracticeDropAsk2: [ + "되돌릴 수 없음 — 정말 통째로 지울까요?", + "This cannot be undone — really delete all?", + ], + M02_PracticeDropped: ["실무문서 {n}개를 지움", "Deleted {n} practice documents"], + M02_PracticeStale: [ + "그 사이 실무문서가 바뀜 — 목록을 다시 받음", + "Practice documents changed — reloaded", + ], } as const; From 364d22ff6fac4a9f09e647f54bf618943bcdb9b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:37:46 +0900 Subject: [PATCH 16/36] =?UTF-8?q?feat(M02):=20=EC=83=88=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=91=90=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=E2=80=94=20=E3=80=8C=EC=83=88=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=EC=83=81=EC=84=B8=20=EC=B9=B8=20[?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0]=20[=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=83=9D=EC=84=B1]=20=C2=B7=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=3D=20=ED=83=AD=20+=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=E2=86=92=20=EC=A7=80=EA=B8=88=20=EC=B8=B5=20=C2=B7?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EC=83=88?= =?UTF-8?q?=20=ED=82=A4=20=C2=B7=20=EC=83=88=20=EB=B2=88=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H4 — 나무 copyIn(붙여넣기 길 · 새 열 하나면 그 열 고름) · folder 손잡이 · ItemPick buildTwoWay(빈 카드도 씀). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 20 +++++++ .../M02_MasterTemplete_ItemPick.ts | 52 ++++++++++++++++++- .../M02_MasterTemplete_TreePanel.ts | 42 +++++++++++++-- .../M02_MasterTemplete_UI_Page.ts | 29 ++++++++++- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index 70f57c665..a6713f59b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -13,3 +13,23 @@ .ui-modal.m02-itempick__dialog { width: min(960px, 94vw); } + +/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */ +.m02-itempick__two { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-itempick__buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index dc1fbc122..54cc67a51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -7,7 +7,7 @@ * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). * ========================================================================== */ -import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; @@ -30,6 +30,8 @@ const TEXT = { person: "사람", noPeople: "다른 사람이 없음", failed: "목록을 못 받음 — {value}", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ @@ -146,6 +148,54 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { }; } +export interface TwoWayOptions { + /** 위 안내 한 줄 */ + note: string; + /** [새로 만들기] — 지금 길(창 · 빈 문서) */ + onNew: () => void; + /** [복사 생성] 뒤 고를 목록 */ + picker: Omit; + /** 줄 누름 → 확인 글(취소면 그대로 고름) */ + ask: (picked: Picked) => string; + /** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */ + onCopy: (picked: Picked) => Promise; +} + +/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */ +export function buildTwoWay(opt: TwoWayOptions): HTMLElement { + const fresh = createButton({ label: TEXT.makeNew, variant: "filled" }); + const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); + const below = el("div", { className: "m02-itempick__below" }); + let busy = false; + fresh.addEventListener("click", opt.onNew); + copy.addEventListener("click", () => { + if (below.firstChild) return; + copy.classList.add("is-active"); + const picker = buildPicker({ + ...opt.picker, + onPick: (picked) => + void (async () => { + if (busy || !(await showConfirmDialog(opt.ask(picked)))) return; + busy = true; // 연달아 눌러도 한 번만 + try { + await opt.onCopy(picked); + } finally { + busy = false; + } + })(), + }); + below.append(picker.root); + }); + return el("div", { + className: "m02-itempick__two", + children: [ + el("p", { className: "m02-itempick__note", text: opt.note }), + el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }), + below, + ], + }); +} + /** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ export function pickFrom( opt: Omit & { title: string; ask?: (picked: Picked) => string }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..bb070c057 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -107,6 +107,14 @@ export interface TreePanelHandle { select: (colId: string | null) => void; /** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */ newItem: () => void; + /** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */ + folder: () => string[]; + /** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */ + copyIn: ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ) => Promise; /** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */ editFromTable: (body: TreeEdit, colId: string | null) => Promise; } @@ -417,8 +425,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; const made = note?.열; - const col = - typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null; + // 붙여넣기로 새 열이 하나면(복사 생성) 그 열 + const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null; + const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); @@ -643,13 +652,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { }); /** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */ - const newItem = (): void => { + const folder = (): string[] => { const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined; - void addItem(at?.kind === "folder" ? at.path : []); + return at?.kind === "folder" ? at.path : []; + }; + const newItem = (): void => void addItem(folder()); + + const copyIn = async ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ): Promise => { + const run = api.paste; + if (!run || !opt.canEdit || !(await ready())) return; + await apply(() => + run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + ); }; const editFromTable = async (body: TreeEdit, colId: string | null): Promise => { if (opt.canEdit && (await ready())) await edit(body, colId); }; - return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable }; + return { + root, + search: search.root, + add: tools, + refresh, + select, + newItem, + folder, + copyIn, + editFromTable, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..4f4d2a852 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -43,8 +43,13 @@ import { import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; import { buildAllItems } from "./M02_MasterTemplete_ItemList"; +import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; +const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안"; +const NEW_ASK = + "{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름"; + /** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ const ALL_TITLE = { structure: "M02_KindStructure", @@ -130,8 +135,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise tree.newItem()) } : {}), + // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) + ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); @@ -145,6 +150,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop"); + main.showView( + L("M02_TreeNewItem"), + buildTwoWay({ + note: NEW_NOTE.replace("{value}", where()), + onNew: () => tree.newItem(), + picker: { user, company, kind: "item" }, + ask: (picked) => + NEW_ASK.replace("{name}", pickedName(picked)) + .replace("{from}", picked.from.label) + .replace("{to}", where()), + onCopy: async (picked) => { + if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder()); + }, + }), + ); + }; // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { layer, From a2d14719319b8672d998c0c61a2d93d65bedc7c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:39:38 +0900 Subject: [PATCH 17/36] =?UTF-8?q?feat(M02):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=A7=A1=EC=9D=80=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=E2=80=94=20accessFor=20=EC=A7=9D=20=C2=B7=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=3D=20=ED=9A=8C=EC=82=AC=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=20=EB=82=98=EB=AC=B4(=EB=82=98=20=EB=A7=A8=20?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=EB=B9=84=EC=96=B4=20=EC=9E=88=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=E3=80=87=E3=80=87=EB=A1=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=E2=80=A6=E3=80=8D=20=EB=B3=BC=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=C2=B7=20=EB=8C=80=EC=83=81=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD(39-6=20E5~E?= =?UTF-8?q?8)?= 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_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Api_Fetch.ts | 4 ++- .../M02_MasterTemplete_Layer.ts | 13 ++++++--- .../M02_MasterTemplete_TreePanel.ts | 23 ++++++++------- .../M02_MasterTemplete_TreePanel_CopyTo.ts | 26 +++++++++++++++++ .../M02_MasterTemplete_TreePanel_Roots.ts | 14 ++++++---- .../M02_MasterTemplete_UI_Page.ts | 9 ++++-- resources/tester/test_m02_layer_screen.py | 28 ++++++++++++++----- ui_template/ui_template_locale_m2.ts | 9 +++--- 8 files changed, 91 insertions(+), 35 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 0ef688765..6ac97e28e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -510,9 +510,11 @@ export const pasteTree = ( export interface LayerOwner { owner: string; name: string; + /** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */ + 비어있음?: boolean; } -/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */ +/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */ export const fetchOwners = ( layer: "company" | "personal", company: string | null = null, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 3b94c655c..c1860e584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -79,7 +79,8 @@ export interface LayerAccess { needCompany: boolean; } -/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */ +/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) · + * 읽을 수 있나는 서버가 가름 */ export function accessFor( layer: ManageLayer, owner: string | null, @@ -87,10 +88,14 @@ export function accessFor( ): LayerAccess { if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false }; const own = ownOwner(layer, user); - if (owner && owner !== own) return { canWrite: false, needCompany: false }; + if (owner && owner !== own) { + return { canWrite: manages(owner.split("/")[0], user), needCompany: false }; + } if (!own) return { canWrite: false, needCompany: true }; - const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master; - return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; + return { + canWrite: layer === "personal" || manages(ownOwner("company", user), user), + needCompany: false, + }; } /** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..13c61f1db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -35,6 +35,7 @@ import { type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; +import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { @@ -83,8 +84,8 @@ export interface TreePanelOptions { }; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; - /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ - copyTo?: { layer: Layer; owner: string | null; label: string }[]; + /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ + copyTo?: CopyTarget[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -533,18 +534,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); } - /** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */ - async function copyTo( - list: Target[], - to: { layer: Layer; owner: string | null; label: string }, - ): Promise { + /** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */ + async function copyTo(list: Target[], to: CopyTarget): Promise { if (!api.paste || !list.length) return; const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) }; try { - await api.paste(to.layer, to.owner, clip, []); - const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, ""); + const dest = await askCopyFolder(api.fetch, to, list.length); + if (!dest) return; + await api.paste(to.layer, to.owner, clip, dest); + const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); showToast( - L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where), + L("M02_TreeCopiedTo") + .replace("{n}", String(list.length)) + .replace("{layer}", where) + .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), "success", ); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts new file mode 100644 index 000000000..38b03c5f6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -0,0 +1,26 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_CopyTo.ts + * 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null. + * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. + * ========================================================================== */ + +import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; + +export interface CopyTarget { + layer: Layer; + owner: string | null; + /** 메뉴 글 — `{n}` = 개수 */ + label: string; +} + +export async function askCopyFolder( + fetchTree: (layer: Layer, owner: string | null) => Promise, + to: CopyTarget, + n: number, +): Promise { + const view = await fetchTree(to.layer, to.owner).catch(() => null); + const folders = view ? listFolders(buildFolders(view)) : []; + return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index bd748d131..4c4ba094e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -4,8 +4,8 @@ * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) - * 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무 - * (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만 + * 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무 + * (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로 * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ @@ -18,6 +18,7 @@ import { accessFor, buildNeedCompany, layerOfRoot, + manages, ownOwner, type Root, } from "./M02_MasterTemplete_Layer"; @@ -40,9 +41,9 @@ export interface LayerBarOptions { export const companyOwner = (company: string | null, user: SessionUser | null): string | null => company && company !== ownOwner("company", user) ? company : null; -/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */ +/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */ export const seesOthers = (company: string | null, user: SessionUser | null): boolean => - user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user); + manages(company, user); export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; @@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { } function button(root: Root, owner: string | null, people = false): HTMLButtonElement { - const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite; + const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite; const name = L( root === "master" ? "M02_PickerSystem" @@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement { box.replaceChildren( ...rows.flatMap((one) => { const current = one.owner === opt.active; - const node = { id: one.owner, label: one.name, count: null, folder: true }; + const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name; + const node = { id: one.owner, label, count: null, folder: true }; const row = createTreeRow(node, current ? "▾" : "▸", 0, () => { if (!current) return opt.go(one.owner); wrap.hidden = !wrap.hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..63227afb3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -76,12 +76,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { const to = layerOfRoot(one); - if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return []; + const toOwner = one === "company" ? companyOwner(company, user) : null; + if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; - return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }]; + return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; }); const tree = createTreePanel({ layer, diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 6ffa334e9..721a75faa 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -26,11 +26,12 @@ def _text(path: Path) -> str: @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_층별_고칠_권한(): - """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만.""" + """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침.""" text = _text(LAYER) own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M) found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M) - assert own and found, "ownOwner · accessFor 를 못 찾음" + manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M) + assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음" script = ( 'type ManageLayer = "system" | "company" | "personal";\n' "interface SessionUser { id: string; role?: string; company_id?: number | null;" @@ -38,6 +39,8 @@ def test_층별_고칠_권한(): "interface LayerAccess { canWrite: boolean; needCompany: boolean }\n" + own.group(0).replace("export ", "") + found.group(0).replace("export ", "") + + "\n" + + manages.group(0).replace("export ", "") + "\nconst cases = JSON.parse(process.argv[1]);" + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));" ) @@ -54,7 +57,8 @@ def test_층별_고칠_권한(): ["personal", None, user], ["personal", None, admin], ["personal", None, orphan], ["personal", None, sysadmin], ["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin], - ["personal", "9/90", sysadmin], + ["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin], + ["company", "9", admin], ] # fmt: skip result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -69,7 +73,8 @@ def test_층별_고칠_권한(): ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만 ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만 ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내 - ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만 + ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침 + ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403) ] # fmt: skip @@ -181,7 +186,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰 assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무 assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기 - assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots + assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6) + assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침 + assert 'L("M02_PeopleEmpty")' in roots assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page @@ -269,12 +276,19 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · - 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" + 30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page + # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 + assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page + assert "(to === layer && toOwner === here)) return [];" in page + copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts") + assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to + assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text( + M02 / "M02_MasterTemplete_TreePanel.ts" + ) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c0c5e4e46..be85441fb 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -105,6 +105,7 @@ export const ui_locales_m2 = { M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], M02_SourcePeople: ["다른 사람", "Others"], M02_SourcePractice: ["실무문서", "Practice documents"], + M02_PeopleEmpty: ["비어 있음", "empty"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", @@ -144,10 +145,10 @@ export const ui_locales_m2 = { M02_NewItemPickCombo: ["고르기", "Pick"], M02_TreePickCombo: ["일위대가 고르기", "Pick unit price"], M02_TableFold: ["표 접기 · 펴기", "Fold or unfold the table"], - M02_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"], - M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"], - M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"], - M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"], + M02_TreeCopyToMaster: ["마스터로 복사… ({n}개)", "Copy to master… ({n})"], + M02_TreeCopyToCompany: ["회사로 복사… ({n}개)", "Copy to company… ({n})"], + M02_TreeCopyToPersonal: ["개인으로 복사… ({n}개)", "Copy to personal… ({n})"], + M02_TreeCopiedTo: ["{n}개를 {layer} 「{folder}」에 복사함", "Copied {n} to {layer} “{folder}”"], M02_TreeFolderName: ["폴더 이름", "Folder name"], M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], From 8f5a573ebcf7613ac9224e120a4651d92ecb7212 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:40:31 +0900 Subject: [PATCH 18/36] =?UTF-8?q?fix(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=E3=80=8C=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=82=98=EB=8A=94=20=EB=BA=8C(?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=ED=83=AD)=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=20=E3=80=8C=EB=B9=84=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9D=8C=E3=80=8D=20=C2=B7=20=EC=B2=98=EC=9D=8C=20=3D=20?= =?UTF-8?q?=EC=B2=AB=20=EB=B9=88=20=EC=95=84=EB=8B=8C=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=20=C2=B7=20=EB=B9=88=20=EC=82=AC=EB=9E=8C=EC=9D=80=20=EB=B9=88?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 사람 목록 전부(39-6 E1~E4)에 맞춤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 54cc67a51..3ee87e241 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -20,7 +20,7 @@ import { type ListKind, } from "./M02_MasterTemplete_ItemList"; import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; -import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer"; import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; import "./M02_MasterTemplete_TreePanel.css"; import "./M02_MasterTemplete_ItemPick.css"; @@ -29,6 +29,7 @@ const TEXT = { close: "닫기", person: "사람", noPeople: "다른 사람이 없음", + empty: "비어 있음", failed: "목록을 못 받음 — {value}", makeNew: "새로 만들기", makeCopy: "복사 생성", @@ -58,6 +59,8 @@ const PRACTICE_COLS: Col[] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */ +const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음; export interface PickerOptions { user: SessionUser | null; @@ -106,17 +109,24 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { let owner = tab.owner; let label = tab.label; if (tab.id === "people") { - owners ??= await fetchOwners("personal", opt.company); + // 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람 + const me = ownOwner("personal", opt.user); + owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me); if (mine !== seq) return; if (!owners.length) return list.message(TEXT.noPeople); if (!people.select.value) { people.setOptions( - owners.map((o) => ({ value: o.owner, text: o.name })), - owners[0].owner, + owners.map((o) => ({ + value: o.owner, + text: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name, + })), + (owners.find((o) => !empty(o)) ?? owners[0]).owner, ); } owner = people.select.value; - label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + const who = owners.find((o) => o.owner === owner); + label = `${tab.label} · ${who?.name ?? owner}`; + if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음 } const rows = await loadItems(tab.layer, owner); if (mine !== seq) return; From d847f724cff5f11283b0650579397183c17091e8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:42:59 +0900 Subject: [PATCH 19/36] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20=E2=80=94?= =?UTF-8?q?=20=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81=20=ED=91=9C=20=C2=B7?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EB=AA=A9=EB=A1=9D=20=ED=91=9C=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20calc=20=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80(39-3=20C3=20=C2=B7=20C4=20=C2=B7=20C6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Labor_Detail.ts(mountLaborDetail · 차례 = 정보 → 담은 로직 → 자재목록 → 다이어그램 · 고칠 때마다 calc 를 뒤에서 · 최신 답만 · 같은 로직 · 자재 두 번 막음) - 새 Labor_Input.ts(담은 로직 줄 입력 모달 · 빈 칸 = 자동값 · 메모 · 수량 = 이은 칸 / 손 입력 / 면적산출에서 잇기 · 사라진 입력값 알림 · 자재 수량 모달) - 새 Labor_Rows.ts(작은 글 「지역 서울 · 수량 B6 = 12.5㎡」 · 빈 값 n 붉게 · 금액 칸 넷 두 줄(위 기준 · 아래 반영) · 합계 줄 · 보기 갈래는 손잡이 없이) - Labor_Api.ts: labor 문서 · calc 답 모양 · calcLabor · fetchLogicInputs · previewCombo 지움 - Labor_Diagram.ts: buildDiagramData → buildLaborDiagram(줄 입력값 + calc · 수량 있으면 반영 · 없으면 기준 표기 · 자재목록 카드 하나 → 재료비 · 알약 = 입력 모달) - 미리 보기 지움: Labor_Preview.ts · M02 preview 길 · Combo_Store.preview · 옛 조합 상세의 미리 보기 · 다이어그램 칸 - 시험: test_m02_labor_diagram 새 모양 · test_m01_combo 미리 보기 시험은 같은 계산(combo_preview)을 바로 부름 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Combo_Store.py | 6 - .../M02_MasterTemplete_Labor.css | 102 ++++++ .../M02_MasterTemplete_Labor_Api.ts | 125 +++++-- .../M02_MasterTemplete_Labor_Detail.ts | 328 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Diagram.css | 10 + .../M02_MasterTemplete_Labor_Diagram.ts | 146 +++++--- .../M02_MasterTemplete_Labor_Edit.ts | 36 +- .../M02_MasterTemplete_Labor_Input.ts | 241 +++++++++++++ .../M02_MasterTemplete_Labor_Preview.ts | 244 ------------- .../M02_MasterTemplete_Labor_Rows.ts | 283 +++++++++++++++ .../M02_MasterTemplete_Labor_Text.ts | 44 ++- .../M02_MasterTemplete_Router_Combos.py | 20 +- resources/tester/test_m01_combo.py | 15 +- resources/tester/test_m02_labor_diagram.py | 92 +++-- 14 files changed, 1266 insertions(+), 426 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 52a846549..ca8d399dd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -190,12 +190,6 @@ def pills(key: str) -> dict[str, Any]: return combo_store.combo_pills(_find(_load()[0], key)) -def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]: - """저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키.""" - row = _find(_load()[0], combo) if isinstance(combo, str) else combo - return _m01(combo_store.combo_preview, row, inputs) - - # ── 쓰기(회사 · 개인) ────────────────────────────────────────────────── def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: """서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든).""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index f6791ee2e..6f435b42d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -21,3 +21,105 @@ .m02-labor__list tbody tr:hover { background: var(--color-mist-violet); } + +/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */ +.m02-labrow { + width: 100%; +} + +.m02-labrow__row.is-open { + cursor: pointer; +} + +.m02-labrow__row.is-open:hover { + background: var(--color-mist-violet); +} + +.m02-labrow__name { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 12rem; +} + +.m02-labrow__key, +.m02-labrow__note, +.m02-labrow__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-labrow__empty { + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labrow__money, +.m02-labrow__num { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-labrow__money { + vertical-align: top; +} + +.m02-labrow__base { + display: block; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labrow__applied { + display: block; +} + +.m02-labrow__sum td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + +.m02-labrow__move { + white-space: nowrap; +} + +/* 입력 모달 */ +.m02-labin { + width: min(40rem, calc(100vw - 32px)); +} + +.m02-labin__qty { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m02-labin__qty-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-labin__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labin__lost { + margin: 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labin__foot { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + margin-top: var(--spacing-12); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 4dfb87e84..026566821 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,7 +12,12 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + getActiveLayer, + getActiveOwner, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; /** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ export interface ComboLogic { @@ -56,28 +61,6 @@ export interface ComboOne { combo: Combo; 줄: ComboLine[]; } -export interface PreviewLine { - 차례: number; - 로직: string; - 이름: string; - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} -export interface PreviewAnswer { - 줄: PreviewLine[]; - 노무비: number; - 재료비: number; - 경비: number; - 계: number; - 멈춤: string[]; -} - /** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */ export interface LaborScope { layer: Exclude; @@ -227,13 +210,6 @@ export const pasteCombos = ( 판, }); -/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ -export const previewCombo = ( - combo: Combo | string, - inputs: Record>, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/preview"), "POST", { combo, inputs }); - /* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ export const fetchLibLogics = (): Promise => request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); @@ -248,3 +224,92 @@ export const fetchLibAuto = (key: string): Promise> => request<{ 값?: Record }>(`/logic-lib/auto?key=${enc(key)}`) .then((d) => d.값 ?? {}) .catch(() => ({})); + +/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ + +/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ +export type LaborQty = { 손: number } | null; +/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */ +export interface LaborRow { + 로직: string; + 메모: string | null; + 입력값: Record; + 수량: LaborQty; +} +export interface LaborMat { + 코드: string; + 이름: string | null; + 규격: string | null; + 단위: string | null; + 수량: LaborQty; +} +export interface LaborDoc { + 종류: "일위대가"; + 판?: number; + 열: string; + 일위대가번?: string; + 단위: string | null; + 본뜬?: { 층: string; 주인: string | null; 키: string } | null; + 담은로직: LaborRow[]; + 자재: LaborMat[]; + 계산값?: unknown; +} +export interface LaborCost { + 노무비: number; + 재료비: number; + 경비: number; + 계: number; +} +/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */ +export interface LaborCalcLine { + 로직: string; + 분모: string | null; + 배: number | null; + 수량: number | null; + 수량출처: string | null; + 기준: LaborCost | null; + 반영: LaborCost | null; + 결과?: number | null; + 까닭: string | null; +} +export interface LaborCalcMat { + 코드: string; + 기준단가: number | null; + 수량: number | null; + 수량출처: string | null; + 반영: number | null; + 까닭: string | null; +} +export interface LaborCalc { + 줄: LaborCalcLine[]; + 자재: LaborCalcMat[]; + 기준합: Partial; + 반영합: Partial; +} + +/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ +const laborAt = (layer: Layer, column: string, tail: string): string => { + const owner = layer === getActiveLayer() ? getActiveOwner() : null; + const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; + return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; +}; + +/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */ +export const calcLabor = ( + layer: Layer, + column: string, + doc: LaborDoc | null, + 수량?: Record, +): Promise => + request(laborAt(layer, column, "/calc"), "POST", { + ...(doc ? { doc } : {}), + ...(수량 ? { 수량 } : {}), + }); + +/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */ +export const fetchLogicInputs = ( + keys: string[], +): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> => + keys.length + ? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`) + : Promise.resolve({ 로직: [], 빠진로직: [] }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts new file mode 100644 index 000000000..d56ecc546 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -0,0 +1,328 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Detail.ts + * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 + * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. + * · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장] + * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 + * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { ROUTES } from "@config/config_frontend"; +import { navigateTo } from "../A00_Common/router"; +import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; +import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + calcLabor, + fetchLogicInputs, + type LaborCalc, + type LaborDoc, + type LaborMat, + type LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { + buildLaborDiagram, + diagramTitle, + renderComboDiagram, +} from "./M02_MasterTemplete_Labor_Diagram"; +import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { keepKnown } from "./M02_Labor_Values_Model"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; +import "./M02_MasterTemplete_Labor.css"; + +/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ +export function normalizeLabor(doc: unknown, column: string): LaborDoc { + const d = (doc && typeof doc === "object" ? doc : {}) as Partial; + return { + ...d, + 종류: "일위대가", + 열: d.열 ?? column, + 단위: d.단위 ?? null, + 담은로직: (d.담은로직 ?? []).map((r) => ({ + 로직: r.로직, + 메모: r.메모 ?? null, + 입력값: r.입력값 ?? {}, + 수량: r.수량 ?? null, + })), + 자재: (d.자재 ?? []).map((m) => ({ + 코드: m.코드, + 이름: m.이름 ?? null, + 규격: m.규격 ?? null, + 단위: m.단위 ?? null, + 수량: m.수량 ?? null, + })), + }; +} + +export interface LaborDetailOptions { + layer: Layer; + column: string; + readOnly: boolean; + /** 저장 안 한 고침이 생기거나 사라짐 */ + onChanged: (dirty: boolean) => void; + /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; + /** 자재 하나 고르기(`M02_Material_Pick`) — 없으면 [자재 추가] 없음 */ + pickMaterial?: (done: (m: Omit) => void) => void; +} + +export interface LaborDetailHandle { + getDoc: () => LaborDoc; + destroy: () => void; +} + +export function mountLaborDetail( + host: HTMLElement, + doc: unknown, + opt: LaborDetailOptions, +): LaborDetailHandle { + const cur = normalizeLabor(doc, opt.column); + const saved = JSON.stringify(cur); + const edit = !opt.readOnly; + const logics = new Map(); + let calc: LaborCalc | null = null; + let fail: string | null = null; + let seq = 0; + let timer = 0; + let alive = true; + + const logicHost = el("div"); + const matHost = el("div"); + const dgHost = el("div"); + const dgBox = el("details", { + className: "m01lab__box m01-logic__section m02-labor__dg", + attrs: { open: "" }, + children: [el("summary", { text: diagramTitle() }), dgHost], + }); + + const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); + const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); + + const drawDg = (): void => { + if (!dgBox.open) return; + const data = buildLaborDiagram({ + rows: cur.담은로직, + logics: [...logics.values()], + calc, + mats: cur.자재.length, + }); + renderComboDiagram(dgHost, data, { + focusInput: (key) => { + const i = cur.담은로직.findIndex((r) => r.로직 === key); + if (i >= 0) openRow(i); + }, + // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) + openLogic: (key) => { + setPendingLogicKey(key); + navigateTo(ROUTES.M01_MASTER_DATA); + }, + }); + }; + dgBox.addEventListener("toggle", drawDg); + + const draw = (): void => { + logicHost.replaceChildren( + logicTable( + { rows: cur.담은로직, logics, calc, fail }, + // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) + edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + ), + ); + matHost.replaceChildren( + matTable( + { mats: cur.자재, calc, fail }, + edit ? { open: openMat, remove: removeMat } : { open: openMat }, + ), + ); + drawDg(); + }; + + /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + calc = null; + fail = null; + draw(); + try { + const got = await calcLabor(opt.layer, opt.column, cur); + if (!alive || mine !== seq) return; + calc = got; + } catch (error) { + if (!alive || mine !== seq) return; + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + draw(); + }, 150); + }; + + const changed = (): void => { + opt.onChanged(JSON.stringify(cur) !== saved); + recalc(); + }; + + /** 아직 안 받은 로직 입력 목록만 받음 */ + const loadLogics = async (): Promise => { + const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + if (!want.length) return; + try { + const got = await fetchLogicInputs(want); + for (const l of got.로직) logics.set(l.로직, l); + } catch (error) { + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + }; + + function move(i: number, d: number): void { + const j = i + d; + if (j < 0 || j >= cur.담은로직.length) return; + [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; + changed(); + } + function removeRow(i: number): void { + cur.담은로직.splice(i, 1); + changed(); + } + function removeMat(i: number): void { + cur.자재.splice(i, 1); + changed(); + } + + function openRow(i: number): void { + const row: LaborRow = cur.담은로직[i]; + const logic = logics.get(row.로직) ?? null; + const line = lineOf(row.로직); + openLaborInput({ + title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), + logic, + values: row.입력값, + memo: row.메모, + seat: { + qty: row.수량, + linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null, + unit: line?.분모 ?? "", + }, + readOnly: !edit, + goArea: opt.goArea, + done: (next) => { + row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + row.메모 = next.메모; + row.수량 = next.수량; + changed(); + }, + }); + } + + function openMat(i: number): void { + const mat = cur.자재[i]; + const line = matOf(mat.코드); + openQtyModal( + `${mat.코드} ${mat.이름 ?? ""}`.trim(), + { + qty: mat.수량, + linked: + line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null + ? `${line.수량출처} = ${line.수량}` + : null, + unit: mat.단위 ?? "", + }, + !edit, + opt.goArea, + (qty) => { + mat.수량 = qty; + changed(); + }, + ); + } + + const addLogic = createButton({ + label: tc("AddLogic"), + variant: "filled", + size: "sm", + onClick: () => + openLogicPicker((x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(changed); + }), + }); + const pick = opt.pickMaterial; + const addMat = pick + ? createButton({ + label: tc("Labor_AddMat"), + variant: "filled", + size: "sm", + onClick: () => + pick((m) => { + if (cur.자재.some((x) => x.코드 === m.코드)) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); + cur.자재.push({ ...m, 수량: null }); + changed(); + }), + }) + : null; + + const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); + unit.input.value = cur.단위 ?? ""; + unit.input.readOnly = !edit; + unit.input.maxLength = 20; + unit.input.setAttribute("data-labor-field", "단위"); + unit.input.addEventListener("input", () => { + cur.단위 = unit.input.value.trim() || null; + opt.onChanged(JSON.stringify(cur) !== saved); + }); + const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); + origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.readOnly = true; + + const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [el("h3", { text: title }), ...(edit && button ? [button] : [])], + }), + body, + ], + }); + + host.replaceChildren( + el("div", { + className: "m01-logic__editor m01-combo m02-labor__edit", + attrs: { "data-labor": opt.column }, + children: [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })], + }), + section(tc("Logics_Title"), addLogic, logicHost), + section(tc("Labor_Mats"), addMat, matHost), + dgBox, + ], + }), + ); + draw(); + void loadLogics().then(() => { + if (alive) recalc(); + }); + + return { + getDoc: () => cur, + destroy: () => { + alive = false; + window.clearTimeout(timer); + host.replaceChildren(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css index fa21deb41..67a16d23a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -309,3 +309,13 @@ cursor: pointer; font-weight: var(--font-weight-bold); } + +/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */ +.m02-labordg__pill.is-auto .m02-labordg__pill-value { + font-style: italic; + color: var(--color-text-secondary); +} + +.m02-labordg__card.is-material { + cursor: default; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 0348ae7b4..9125b3042 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -1,8 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Diagram.ts * 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값) - * 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) - * 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT + * 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) + * 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) · + * 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -29,7 +30,9 @@ const TEXT = { "누르면 이 로직을 단가산출 로직 화면에서 엶", "Open this logic in the unit-price logic screen", ], - Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"], + Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"], + Mats: ["자재목록", "Materials"], + Auto: ["자동값", "auto"], Same: ["같은 이름의 넣을 값", "Same input name"], Won: ["원", "KRW"], Title: ["다이어그램", "Diagram"], @@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number]; export interface DiagramInput { name: string; unit: string; - /** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */ + /** 넣은 값 — 빈 글 = 값 없음 */ value: string; + /** 넣은 값이 없어 자동값을 보임 */ + auto?: boolean; } /** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */ @@ -64,6 +69,10 @@ export interface DiagramLogic { unit: string; inputs: DiagramInput[]; out: DiagramOut; + /** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */ + basis?: string; + /** 자재목록 카드 — 누름 없음 */ + material?: boolean; } export interface DiagramData { @@ -73,53 +82,86 @@ export interface DiagramData { export interface DiagramHandlers { /** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */ openLogic(key: string): void; - /** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */ + /** 넣을 값 알약 누름 — 그 로직 입력 모달 */ focusInput(key: string, name: string): void; } -/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */ +/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */ -interface PreviewLineLike { - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} +type Money = { 노무비: number; 재료비: number; 경비: number; 계: number }; -export interface DiagramSource { +export interface LaborDiagramSource { /** 담은 차례대로 */ - rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[]; - /** 로직 키 → 입력 이름 → 넣은 값 */ - values: Record>; - /** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */ - lines: PreviewLineLike[] | null; + rows: { 로직: string; 입력값: Record }[]; + /** 로직 입력 목록(`/logic-lib/inputs`) */ + logics: { + 로직: string; + 이름: string; + 결과단위?: string; + 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; + }[]; + /** calc 답 · 아직이면 null */ + calc: { + 줄: { + 로직: string; + 기준: Money | null; + 반영: Money | null; + 결과?: number | null; + 까닭: string | null; + }[]; + 자재: { 기준단가: number | null; 반영: number | null }[]; + } | null; + /** 담은 자재 수 — 0 이면 자재 카드 없음 */ + mats: number; } -const outOf = (l: PreviewLineLike | undefined): DiagramOut => { - if (!l) return null; - if (!l.ok) return { 멈춤: l.까닭 ?? "" }; - if (l.갈래 === "결과") return { 값: l.결과 ?? 0 }; - return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 }; -}; +const autoOf = (v: unknown): string => + typeof v === "string" || typeof v === "number" ? String(v) : ""; -export function buildDiagramData(src: DiagramSource): DiagramData { - return { - logics: src.rows.map((r, i) => ({ - key: r.key, - name: r.name, - unit: r.unit, - inputs: r.inputs.map((x) => ({ - name: x.name, - unit: x.unit, - value: src.values[r.key]?.[x.name] ?? "", - })), - out: outOf(src.lines?.[i]), - })), - }; +export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { + const logics: DiagramLogic[] = src.rows.map((r) => { + const def = src.logics.find((l) => l.로직 === r.로직); + const line = src.calc?.줄.find((l) => l.로직 === r.로직); + let out: DiagramOut = null; + let basis: string | undefined; + if (line?.까닭) out = { 멈춤: line.까닭 }; + else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 }; + else if (line && (line.반영 || line.기준)) { + out = { ...((line.반영 ?? line.기준) as Money) }; + basis = line.반영 ? "반영" : "기준"; + } + return { + key: r.로직, + name: def?.이름 ?? r.로직, + unit: def?.결과단위 ?? "", + inputs: (def?.입력 ?? []).map((x) => { + const mine = r.입력값[x.이름]; + const has = mine !== undefined && mine !== ""; + return { + name: x.이름, + unit: x.단위 ?? "", + value: has ? String(mine) : autoOf(x.자동값), + ...(has || !autoOf(x.자동값) ? {} : { auto: true }), + }; + }), + out, + ...(basis ? { basis } : {}), + }; + }); + if (src.mats > 0) { + const mats = src.calc?.자재 ?? null; + const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0; + logics.push({ + key: "", + name: `${td("Mats")} ${src.mats}`, + unit: "", + inputs: [], + out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null, + ...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}), + material: true, + }); + } + return { logics }; } /* ── 글 ─────────────────────────────────────────────────────────────────── */ @@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o; const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0); function summary(l: DiagramLogic): string { - if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; + if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim(); if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`; return td("Wait"); @@ -360,8 +402,12 @@ export function renderComboDiagram( ? l.inputs.map((x) => { const empty = x.value.trim() === ""; const pill = el("button", { - className: `m02-labordg__pill${empty ? " is-empty" : ""}`, - attrs: { type: "button", "data-name": x.name, title: td("Goto") }, + className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`, + attrs: { + type: "button", + "data-name": x.name, + title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"), + }, children: [ el("span", { className: "m02-labordg__pill-name", text: x.name }), el("span", { @@ -376,8 +422,12 @@ export function renderComboDiagram( : [], }); const card = el("button", { - className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`, - attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") }, + className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`, + attrs: { + type: "button", + style: `grid-column:3;grid-row:${row}`, + ...(l.material ? {} : { title: td("Open") }), + }, children: [ el("span", { className: "m02-labordg__card-key", text: l.key }), el("strong", { className: "m02-labordg__card-name", text: l.name }), @@ -385,7 +435,7 @@ export function renderComboDiagram( el("span", { className: "m02-labordg__card-out", text: summary(l) }), ], }); - card.addEventListener("click", () => on.openLogic(l.key)); + if (!l.material) card.addEventListener("click", () => on.openLogic(l.key)); const mine = bins.map((b, c) => { const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b); sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index f09b181de..dfa1780f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Edit.ts * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 - * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). + * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 · + * 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감. * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ @@ -12,10 +13,7 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { ROUTES } from "@config/config_frontend"; -import { navigateTo } from "../A00_Common/router"; import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { deleteCombo, editCombo, @@ -26,9 +24,7 @@ import { type ComboSummary, type LaborScope, } from "./M02_MasterTemplete_Labor_Api"; -import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { matchText } from "./M02_MasterTemplete_TreePanel_Model"; import { loadingError, showLoading } from "@ui/ui_template_loading"; @@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }); return f.root; }; - const preview = buildPreview(() => combo); - // 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면 - const dgHost = el("div"); - const dgBox = el("details", { - className: "m01lab__box m01-logic__section m02-labor__dg", - attrs: { open: "" }, - children: [el("summary", { text: diagramTitle() }), dgHost], - }); - const drawDg = (): void => { - if (dgBox.open) - renderComboDiagram(dgHost, preview.data(), { - focusInput: preview.focusInput, - openLogic: async (logic) => { - if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return; - setPendingLogicKey(logic); - navigateTo(ROUTES.M01_MASTER_DATA); - }, - }); - }; - let dgTimer = 0; - preview.onUpdate(() => { - window.clearTimeout(dgTimer); - dgTimer = window.setTimeout(drawDg, 120); - }); - dgBox.addEventListener("toggle", drawDg); const table = el("div", { className: "m01-master__grid-wrap" }); const drawTable = (): void => { const move = (i: number, d: number): void => { @@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }), ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]), ); - void preview.redraw(); }; const add = createButton({ @@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { table, ], }), - dgBox, - preview.root, ); drawTable(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts new file mode 100644 index 000000000..e5d913bff --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -0,0 +1,241 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Input.ts + * 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량 + * (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`). + * [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborQty } from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createValueControl } from "./M02_Labor_Values"; +import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; +import "./M02_MasterTemplete_Labor.css"; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface QtySeat { + /** 지금 수량 */ + qty: LaborQty; + /** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */ + linked: string | null; + /** 손 입력 단위 표기(분모 · 자재 단위) */ + unit: string; +} + +interface QtyPart { + root: HTMLElement; + /** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */ + read: () => LaborQty | undefined; +} + +/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ +function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { + const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; + const radio = (value: "cell" | "hand"): HTMLInputElement => { + const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; + input.disabled = readOnly; + return input; + }; + const cell = radio("cell"); + const hand = radio("hand"); + const number = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "number", step: "any", "data-labor-qty": "hand" }, + }) as HTMLInputElement; + number.readOnly = readOnly; + if (seat.qty) number.value = String(seat.qty.손); + (seat.qty ? hand : cell).checked = true; + number.disabled = !hand.checked; + for (const r of [cell, hand]) + r.addEventListener("change", () => { + number.disabled = !hand.checked; + if (hand.checked) number.focus(); + }); + const go = goArea + ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) + : null; + const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => + el("label", { + className: "m02-labin__qty-line", + children: [input, el("span", { text: label }), ...rest], + }); + const root = el("section", { + className: "m02-labin__qty", + children: [ + el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }), + line( + cell, + tc("In_QtyCell"), + el("small", { + className: "m02-labin__muted", + text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), + }), + ...(go ? [go] : []), + ), + line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), + ], + }); + return { + root, + read: () => { + if (!hand.checked) return null; + const v = Number(number.value); + return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined; + }, + }; +} + +function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { + return el("div", { + className: "m02-labin__foot", + children: readOnly + ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })] + : [ + createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }), + createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + ], + }); +} + +export interface LaborInputResult { + 입력값: LaborValues; + 메모: string | null; + 수량: LaborQty; +} + +export interface LaborInputOptions { + /** 「GF000219 메쌓기」 */ + title: string; + /** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */ + logic: ItemLogic | null; + values: LaborValues; + memo: string | null; + seat: QtySeat; + readOnly: boolean; + /** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; + done: (next: LaborInputResult) => void; +} + +/** 담은 로직 줄 입력 모달 */ +export function openLaborInput(opt: LaborInputOptions): void { + const inputs: ItemInput[] = opt.logic?.입력 ?? []; + const { kept, lost } = keepKnown(inputs, opt.values); + const draft: LaborValues = { ...kept }; + const bad = new Set(); + openModal({ + title: fill(tc("In_Title"), opt.title), + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const rows = inputs.map((input) => { + const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => { + if (isBad) bad.add(input.이름); + else bad.delete(input.이름); + if (value === null) delete draft[input.이름]; + else draft[input.이름] = value; + }); + field.control.disabled = opt.readOnly; + field.control.setAttribute("data-labor-input", input.이름); + const hint = valueHint(input); + return el("div", { + className: "m02-inputval__row", + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") }, + }), + el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }), + ], + }); + }); + const memo = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "text", maxlength: "200", "data-labor-memo": "" }, + }) as HTMLInputElement; + memo.value = opt.memo ?? ""; + memo.readOnly = opt.readOnly; + const qty = qtyPart( + opt.seat, + opt.readOnly, + opt.goArea && + (() => { + close(); + opt.goArea?.(); + }), + ); + const ok = (): void => { + if (bad.size) return showToast(tc("In_Bad"), "error"); + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 }); + close(); + }; + body.append( + el("div", { + className: "m02-inputval", + children: [ + ...(lost.length + ? [ + el("p", { + className: "m02-labin__lost", + text: fill(tc("Labor_Lost"), lost.join(", ")), + }), + ] + : []), + ...(rows.length + ? rows + : [el("p", { className: "m02-labin__muted", text: tc("In_None") })]), + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text: tc("In_Memo") }), + memo, + ], + }), + qty.root, + ], + }), + footer(opt.readOnly, ok, close), + ); + }, + }); +} + +/** 자재 줄 수량 모달 */ +export function openQtyModal( + title: string, + seat: QtySeat, + readOnly: boolean, + goArea: (() => void) | null, + done: (qty: LaborQty) => void, +): void { + openModal({ + title, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const qty = qtyPart( + seat, + readOnly, + goArea && + (() => { + close(); + goArea(); + }), + ); + const ok = (): void => { + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + done(수량); + close(); + }; + body.append(qty.root, footer(readOnly, ok, close)); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts deleted file mode 100644 index 68cc72e01..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음 - * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) - * ========================================================================== */ - -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { - fetchLibAuto as fetchAuto, - fetchLibLogic as fetchLogic, - previewCombo, - type Combo, -} from "./M02_MasterTemplete_Labor_Api"; -import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram"; -import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; - -const COSTS = ["노무비", "재료비", "경비"] as const; -const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { - 노무비: "Prev_Labor", - 재료비: "Prev_Material", - 경비: "Prev_Expense", -}; - -const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ -const fmt = formatMoney; - -/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ -const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); - -/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */ -function inputCell( - input: LogicInput, - key: string, - values: Record>, - onEdit: () => void, - onInput: () => void, -): HTMLElement { - const mine = (values[key] ??= {}); - const value = mine[input.이름] ?? initial(input); - mine[input.이름] = value; - const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); - const field = choices - ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) - : createInputField({ type: "text", label: input.이름, compact: true }); - const control = "select" in field ? field.select : field.input; - control.value = value; - control.setAttribute("data-input", `${key}|${input.이름}`); - const edit = (): void => { - mine[input.이름] = control.value; - onInput(); - }; - control.addEventListener("input", edit); - control.addEventListener("change", () => { - edit(); - onEdit(); // 바꾸면 다시 미리 보기 계산 - }); - return field.root; -} - -export interface PreviewHandle { - root: HTMLElement; - /** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */ - redraw: () => Promise; - /** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */ - data: () => DiagramData; - /** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */ - onUpdate: (fn: () => void) => () => void; - /** 그 입력 칸으로 가서 깜빡임 */ - focusInput: (key: string, name: string) => void; -} - -export function buildPreview(combo: () => Combo): PreviewHandle { - const values: Record> = {}; - /** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */ - const inputs = new Map(); - /** 로직마다 자동값은 한 번만 받음 */ - const asked = new Set(); - /** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */ - const meta = new Map(); - let lines: Parameters[0]["lines"] = null; - const listeners = new Set<() => void>(); - const notify = (): void => listeners.forEach((f) => f()); - const form = el("div", { className: "m01-logic__stack" }); - const out = el("div", { - className: "m01-master__grid-wrap", - attrs: { "data-combo": "preview" }, - }); - const rowOf = (cells: string[], note = "", bold = false): HTMLElement => - el("tr", { - className: bold ? "m01lab__subtotal" : "", - children: cells.map((t, i) => { - const text = i === 0 && note ? `${t} — ${note}` : t; - return el("td", { text, attrs: { title: text } }); - }), - }); - - const run = async (): Promise => { - out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); - lines = null; - notify(); - const send: Record> = {}; - for (const [key, defs] of inputs) { - send[key] = {}; - for (const i of defs) { - const raw = values[key]?.[i.이름] ?? initial(i); - if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) - send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); - } - } - let got; - try { - got = await previewCombo(combo(), send); - lines = got.줄; - notify(); - } catch (error) { - out.replaceChildren( - el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), - ); - return; - } - const trs = got.줄.map((l) => - rowOf( - [ - `${l.로직} ${l.이름}`, - fmt(l.노무비 ?? 0), - fmt(l.재료비 ?? 0), - fmt(l.경비 ?? 0), - fmt(l.계 ?? 0), - ], - l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`, - ), - ); - trs.push( - rowOf( - [tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)], - "", - true, - ), - ); - const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")]; - out.replaceChildren( - el("table", { - className: "m01-master__grid", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: trs }), - ], - }), - ); - }; - - const button = el("button", { - className: "ui-btn ui-btn--filled", - text: tc("Prev_Run"), - attrs: { type: "button", "data-combo": "run" }, - }); - button.addEventListener("click", () => void run()); - - const redraw = async (): Promise => { - const blocks: HTMLElement[] = []; - inputs.clear(); - for (const row of combo().담은로직) { - try { - const [one, auto] = await Promise.all([ - fetchLogic(row.로직), - asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직), - ]); - asked.add(row.로직); - const mine = (values[row.로직] ??= {}); - for (const i of one.logic.입력) { - const got = (auto as Record)[i.이름]; - if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue; - mine[i.이름] = String(got); - } - inputs.set(row.로직, one.logic.입력); - meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" }); - blocks.push( - el("div", { - className: "m01lab__info", - children: [ - el("strong", { - className: "m01lab__cell--wide", - text: `${row.로직} ${one.logic.이름}`, - }), - ...one.logic.입력.map((i) => - inputCell(i, row.로직, values, () => void run(), notify), - ), - ], - }), - ); - } catch { - /* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */ - } - } - form.replaceChildren(...blocks); - notify(); - if (blocks.length) - await run(); // 열자마자 한 번 미리 보기 계산 - else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); - }; - - const root = el("section", { - className: "m01lab__box m01-logic__section", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [el("h3", { text: tc("Prev_Title") })], - }), - el("h4", { text: tc("Prev_Inputs") }), - form, - button, - out, - ], - }); - const data = (): DiagramData => - buildDiagramData({ - rows: combo().담은로직.map((r) => ({ - key: r.로직, - name: meta.get(r.로직)?.name ?? r.로직, - unit: meta.get(r.로직)?.unit ?? "", - inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })), - })), - values, - lines, - }); - const focusInput = (key: string, name: string): void => { - const box = root.querySelector(`[data-input="${CSS.escape(`${key}|${name}`)}"]`); - if (!box) return; - box.scrollIntoView({ block: "center", behavior: "smooth" }); - box.focus({ preventScroll: true }); - box.classList.add("m02-labor__flash"); - window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600); - }; - const onUpdate = (fn: () => void): (() => void) => { - listeners.add(fn); - return () => listeners.delete(fn); - }; - return { root, redraw, data, onUpdate, focusInput }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts new file mode 100644 index 000000000..f8c637ccc --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -0,0 +1,283 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Rows.ts + * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. + * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · + * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. + * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { + LaborCalc, + LaborCalcLine, + LaborCalcMat, + LaborCost, + LaborMat, + LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; +import { valueLine } from "./M02_Labor_Values_Model"; + +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +}; + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */ +export function qtyText( + line: { 수량: number | null; 수량출처: string | null } | undefined, + unit: string, +): string { + if (!line || line.수량 === null || line.수량 === undefined) return ""; + const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; + if (!line.수량출처 || line.수량출처 === "손") + return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; + return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`; +} + +/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ +function moneyCell( + base: number | null | undefined, + applied: number | null | undefined, + wait: boolean, +): HTMLElement { + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + return el("td", { + className: "m02-labrow__money", + children: [ + el("small", { + className: "m02-labrow__base", + text: show(base), + attrs: { title: tc("Labor_Base") }, + }), + el("span", { + className: "m02-labrow__applied", + text: show(applied), + attrs: { title: tc("Labor_Applied") }, + }), + ], + }); +} + +const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap", + children: [ + el("table", { + className: "ui-general-block__table m02-labrow", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ...(foot.length ? [el("tfoot", { children: foot })] : []), + ], + }), + ], + }); + +/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ +export interface RowHandlers { + open: (i: number) => void; + move?: (i: number, d: number) => void; + remove?: (i: number) => void; +} + +export interface LogicTableData { + rows: LaborRow[]; + logics: Map; + /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ + calc: LaborCalc | null; + fail?: string | null; +} + +/** 담은 로직 표 — 보기 갈래는 `on` 없이 */ +export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { + const wait = !data.calc && !data.fail; + const { move, remove } = on ?? {}; + const tools = !!(move && remove); + const lineOf = (key: string): LaborCalcLine | undefined => + data.calc?.줄.find((l) => l.로직 === key); + const trs = data.rows.map((row, i) => { + const logic = data.logics.get(row.로직); + const line = lineOf(row.로직); + const unit = logic?.결과단위 ?? ""; + const values = valueLine(logic?.입력 ?? [], row.입력값); + const qty = qtyText(line, line?.분모 ?? ""); + const note = [values.text, qty].filter(Boolean).join(" · "); + const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); + const small = (label: string, run: () => void): HTMLElement => + createButton({ + label, + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + run(); + }, + }); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-logic": row.로직 }, + children: [ + el("td", { text: String(i + 1) }), + el("td", { + className: "m02-labrow__name", + children: [ + el("span", { className: "m02-labrow__key", text: row.로직 }), + el("strong", { text: logic?.이름 ?? row.로직 }), + ...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []), + ...(values.empty + ? [ + el("small", { + className: "m02-labrow__empty", + text: tc("Labor_Empty").replace("{n}", String(values.empty)), + }), + ] + : []), + ...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []), + ...(why + ? [ + el("small", { + className: "m02-labrow__empty", + text: `${tc("Labor_Calc_Fail")}: ${why}`, + }), + ] + : []), + ], + }), + el("td", { text: unit }), + ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)), + ...(move && remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + small(tc("Remove"), () => remove(i)), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => + part?.[c] ?? null; + const foot = data.rows.length + ? [ + el("tr", { + className: "m02-labrow__sum", + children: [ + el("td", { text: "" }), + el("td", { text: tc("Prev_Sum") }), + el("td", { text: "" }), + ...COSTS.map((c) => + moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait), + ), + ...(tools ? [el("td", { text: "" })] : []), + ], + }), + ] + : []; + const heads = [ + tc("Col_Order"), + tc("Col_Name"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(HEAD[c])), + ...(tools ? [tc("Col_Move")] : []), + ]; + return data.rows.length + ? table(heads, trs, foot) + : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); +} + +export interface MatTableData { + mats: LaborMat[]; + calc: LaborCalc | null; + fail?: string | null; +} + +/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +export function matTable(data: MatTableData, on?: Omit): HTMLElement { + const wait = !data.calc && !data.fail; + const remove = on?.remove; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + const trs = data.mats.map((mat, i) => { + const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); + const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { text: mat.코드 }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: mat.이름 ?? "" }), + ...(line?.까닭 + ? [el("small", { className: "m02-labrow__empty", text: line.까닭 })] + : []), + ], + }), + el("td", { text: mat.규격 ?? "" }), + el("td", { text: mat.단위 ?? "" }), + el("td", { + className: "m02-labrow__num", + text: qty, + attrs: { title: line?.수량출처 ?? "" }, + }), + el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), + el("td", { className: "m02-labrow__num", text: show(line?.반영) }), + ...(remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); + remove(i); + }, + }), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Col_Unit"), + tc("Labor_Qty"), + tc("Labor_BasePrice"), + tc("Labor_AppliedSum"), + ...(remove ? [tc("Col_Move")] : []), + ]; + return data.mats.length + ? table(heads, trs, []) + : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 962d3eab9..5521c55d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -65,16 +65,48 @@ const TEXT = { Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], - Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"], - Prev_Inputs: ["넣을 값", "Inputs"], - Prev_Run: ["미리 보기 계산", "Run preview"], Prev_Labor: ["노무비", "Labor"], Prev_Material: ["재료비", "Material"], Prev_Expense: ["경비", "Expense"], Prev_Sum: ["합계", "Total"], - Prev_Row: ["로직", "Logic"], - Prev_Fail: ["못 셈", "Failed"], - Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"], + // 일위대가 상세(39-3) — 구조물 열마다 하나 + Labor_Title: ["일위대가", "Unit price"], + Labor_Unit: ["단위", "Unit"], + Labor_Origin: ["본뜬 곳", "Copied from"], + Labor_Mats: ["자재목록", "Materials"], + Labor_NoMat: ["담은 자재 없음", "No materials yet"], + Labor_AddMat: ["자재 추가", "Add material"], + Labor_Qty: ["수량", "Quantity"], + Labor_Base: ["기준", "Base"], + Labor_Applied: ["반영", "Applied"], + Labor_BasePrice: ["기준 단가", "Base price"], + Labor_AppliedSum: ["반영 금액", "Applied amount"], + Labor_Calc_Wait: ["계산 중…", "Calculating…"], + Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], + Labor_Empty: ["빈 값 {n}", "{n} empty"], + Labor_Lost: [ + "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", + "Inputs no longer in the logic (dropped on save) — {value}", + ], + Labor_Code: ["코드", "Code"], + Labor_Spec: ["규격", "Spec"], + Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"], + Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], + In_Title: ["넣을 값 — {value}", "Inputs — {value}"], + In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], + In_Memo: ["메모", "Memo"], + In_Qty: ["수량", "Quantity"], + In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyHand: ["손 입력", "Manual"], + In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], + In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], + In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"], + In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"], + In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"], + In_Ok: ["확인", "OK"], + In_Cancel: ["취소", "Cancel"], + In_Close: ["닫기", "Close"], + In_Loading: ["입력 목록 받는 중…", "Loading inputs…"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 0944f8d6d..83bdf69ea 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -5,7 +5,7 @@ company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######) personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######) 저장소 = `M02_MasterTemplete_Combo_Store`. -담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 +담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. """ @@ -62,13 +62,6 @@ class PasteBody(BaseModel): 판: str = "" -class PreviewBody(BaseModel): - """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" - - combo: dict[str, Any] | str - inputs: dict[str, dict[str, Any]] = {} - - @router.get("/combos/all") def get_all(session: Session = Depends(verify_session)) -> list[dict]: """고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층.""" @@ -193,17 +186,6 @@ def post_paste( return _call(folder, combo_copy.paste, source, body.model_dump()) -@router.post("/layers/{layer}/combos/preview") -def post_preview( - layer: Layer, - body: PreviewBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한.""" - return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs) - - # ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ── def _read(fn, *args): try: diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index 6c793c1e2..0b747fe4d 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -25,6 +25,7 @@ from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) @@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None: # ── 미리 보기 ───────────────────────────────────────────────────────── +# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름 +def _preview(body: dict) -> dict: + got = combo_store.combo_preview(body["combo"], body["inputs"]) + return json.loads(json.dumps(got, default=str, ensure_ascii=False)) + + def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: key = _make(client)["key"] each = {k: _inputs(k) for k in SAMPLE} @@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 for name in ("노무비", "재료비", "경비", "계") } - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each}) + got = _preview({"combo": key, "inputs": each}) assert got["멈춤"] == [], got["멈춤"] assert [one["로직"] for one in got["줄"]] == list(SAMPLE) for name in ("노무비", "재료비", "경비", "계"): @@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: before = _list(client) row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} - got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) + got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}}) assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 assert _list(client) == before # 아무것도 안 생김 @@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하 def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: key = _make(client, keys=(STACK, LAYER))["key"] only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only}) + got = _preview({"combo": key, "inputs": only}) stuck = [one for one in got["줄"] if not one["ok"]] assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] assert len(got["멈춤"]) == 1 @@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: before = _get(client, "/api/m01/logic", key=STACK) key = _make(client)["key"] - _post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) + _preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) after = _get(client, "/api/m01/logic", key=STACK) assert after["version"] == before["version"] and after["logic"] == before["logic"] diff --git a/resources/tester/test_m02_labor_diagram.py b/resources/tester/test_m02_labor_diagram.py index bc78539fe..9e07ca7b4 100644 --- a/resources/tester/test_m02_labor_diagram.py +++ b/resources/tester/test_m02_labor_diagram.py @@ -1,4 +1,4 @@ -"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력. +"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력. 화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 · 금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠. @@ -39,7 +39,7 @@ function load(f) { } const m = load(file); const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j)))); +process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j)))); """ @@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list: return json.loads(done.stdout) -ROWS = [ +LOGICS = [ { - "key": "GC000001", - "name": "돌쌓기", - "unit": "원/㎡", - "inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}], + "로직": "GC000001", + "이름": "돌쌓기", + "결과단위": "원/㎡", + "입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}], }, - { - "key": "GF000002", - "name": "작업량", - "unit": "㎥/시간", - "inputs": [{"name": "지역", "unit": ""}], - }, - {"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []}, + {"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]}, + {"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []}, ] +ROWS = [ + {"로직": "GC000001", "입력값": {"높이": 4}}, + {"로직": "GF000002", "입력값": {"지역": "부산"}}, + {"로직": "GC000003", "입력값": {}}, +] +MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} +APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450} -def test_돈_값_멈춤_세_갈래(): - lines = [ - {"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150}, - {"ok": True, "갈래": "결과", "결과": 8.946}, - {"ok": False, "까닭": "표에 맞는 줄이 없음"}, - ] - (got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}]) +def _line(key: str, **more) -> dict: + return {"로직": key, "기준": None, "반영": None, "까닭": None, **more} + + +def test_돈_값_멈춤_세_갈래_반영이_있으면_반영(): + calc = { + "줄": [ + _line("GC000001", 기준=MONEY, 반영=APPLIED), + _line("GF000002", 결과=8.946), + _line("GC000003", 까닭="표에 맞는 줄이 없음"), + ], + "자재": [], + } + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}]) outs = [x["out"] for x in got["logics"]] - assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} + assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영" assert outs[1] == {"값": 8.946} assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"} -def test_넣은_값은_로직별_빈_값은_빈_글(): - (got,) = run( - [ - { - "rows": ROWS, - "values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}}, - "lines": None, - } - ] - ) +def test_수량_없으면_기준(): + calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []} + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준" + + +def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글(): + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) first, second, third = got["logics"] - assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")] + assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")] + assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0] assert first["inputs"][0]["unit"] == "m" - assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")] + assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")] assert third["inputs"] == [] def test_아직_안_셌으면_출력_없음(): - (got,) = run([{"rows": ROWS, "values": {}, "lines": None}]) + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) assert [x["out"] for x in got["logics"]] == [None, None, None] assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"] + + +def test_자재목록은_카드_하나_재료비로(): + calc = { + "줄": [_line("GC000001", 기준=MONEY)], + "자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}], + } + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}]) + card = got["logics"][-1] + assert card["material"] is True and card["inputs"] == [] + assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200} + assert card["basis"] == "기준" # 반영 없는 자재가 있음 + (none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert not any(x.get("material") for x in none["logics"]) From 29922434d64c6c34539628457024095d52653436 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:43:43 +0900 Subject: [PATCH 20/36] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EB=A3=8C=20=ED=8B=80=20=E2=80=94=20?= =?UTF-8?q?=EC=97=B4=EB=A7=88=EB=8B=A4=20labor=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=C2=B7=20SU=20=EB=B2=88=ED=98=B8=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=C2=B7=20calc=20=C2=B7=20pills=20?= =?UTF-8?q?=EA=B8=B8=20(39-1a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - labor 종류 · 일위대가번 SU(도번 · 면적번과 같은 뒷자리) · 빈 문서 · 뼈대 검사 - M02_Labor_Doc: 정리 · 검사(같은 로직 두 번 · 없는 로직 · 자재 · 수량 꼴) · 기준(M01 엔진) · settle(수량 · 반영 · 합) - [저장] · 복사 때 서버가 계산값 다시 적음 · 면적산출 저장 때 그 열 일위대가 계산값도 - 짝 M02_MasterTemplete_Labor_Calc.ts settle + 거울 시험 - 길 POST /layers/{층}/labor/{열}/calc · GET …/pills (main.py 등록) - M01 _PER 「원/1,000본」 배 1000 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M01_MasterData/M01_MasterData_Store_Combo.py | 6 +- M02_MasterTemplete/M02_Labor_Doc.py | 287 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 64 ++++ .../M02_MasterTemplete_Items.py | 3 +- .../M02_MasterTemplete_Labor_Calc.ts | 113 +++++++ .../M02_MasterTemplete_Store.py | 59 +++- M02_MasterTemplete/M02_Template_Layers.py | 4 +- main.py | 2 + resources/tester/helper_m02_labor_calc.cjs | 21 ++ resources/tester/test_m02_item_keys.py | 6 +- .../tester/test_m02_labor_calc_mirror.py | 71 +++++ resources/tester/test_m02_labor_doc.py | 213 +++++++++++++ resources/tester/test_m02_structure.py | 9 +- resources/tester/test_m02_structure_combo.py | 4 +- 14 files changed, 839 insertions(+), 23 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Doc.py create mode 100644 M02_MasterTemplete/M02_Labor_Router.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts create mode 100644 resources/tester/helper_m02_labor_calc.cjs create mode 100644 resources/tester/test_m02_labor_calc_mirror.py create mode 100644 resources/tester/test_m02_labor_doc.py diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 75ecedb5b..cb147ba2a 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -150,7 +150,9 @@ def combo(key: str) -> dict: } -_PER = re.compile(r"원\s*/\s*(\d*\.?\d*)\s*([^\s·(]+)") +_PER = re.compile( + r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)" +) # 「원/1,000본」 = 배 1000 _TAIL = re.compile(r"\s*\([^()]*\)\s*$") @@ -185,7 +187,7 @@ def combo_pills(row: dict) -> dict: (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} ) pill["로직"].append(code) - times = per[0][0] + times = per[0][0].replace(",", "") if len(per) == 1 and times and float(times) != 1: pill["배"] = float(times) if "." in times else int(times) return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []} diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py new file mode 100644 index 000000000..8036a0d01 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -0,0 +1,287 @@ +"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). + +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}], +자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). +수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` +(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). +금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 +반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. +짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — +거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +""" + +from __future__ import annotations + +import json +from decimal import Decimal, InvalidOperation +from typing import Any + +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_MasterTemplete_Store as store + +mf = m01.cm.mf +mm = mf.mp.mm +COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 +MATERIAL_HEAD = "자재|" +_MAT_COLS = ("코드", "이름", "규격", "단위") + + +def _bad(text: str) -> store.StoreError: + return store.StoreError(400, text) + + +def _number(raw: Any) -> Decimal | None: + """칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌).""" + if raw is None or isinstance(raw, bool): + return None + try: + got = Decimal(str(raw).replace(",", "").strip()) + except InvalidOperation: + return None + return got if got.is_finite() else None + + +def _specs(logic: dict) -> list[dict]: + """로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」.""" + if isinstance(logic.get("흐름"), dict): + return mf.inputs_of(logic) + return list(logic.get("입력") or []) + + +def _qty(value: Any, where: str) -> dict | None: + if value is None: + return None + hand = _number(value.get("손")) if isinstance(value, dict) else None + if hand is None: + raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}") + return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)} + + +# ── 정리 · 검사 ─────────────────────────────────────────────────────── +def clean(doc: Any) -> dict[str, Any]: + """서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 · + 모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것.""" + if not isinstance(doc, dict): + raise _bad("일위대가 문서가 묶음 아님") + whole = m01.loaded()[1] + lines, seen = [], set() + for at, one in enumerate(doc.get("담은로직") or [], start=1): + where = f"담은 로직 {at}째" + if not isinstance(one, dict): + raise _bad(f"{where} · 묶음 아님") + code = str(one.get("로직") or "") + try: + logic = whole.logic(code) + except mf.FormulaError as e: + raise _bad(f"{where} · {e}") from e + if code in seen: + raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」") + seen.add(code) + names = {str(spec.get("이름")) for spec in _specs(logic)} + given = one.get("입력값") or {} + if not isinstance(given, dict): + raise _bad(f"{where} · 입력값이 묶음 아님") + memo = one.get("메모") + lines.append( + { + "로직": code, + "메모": str(memo) if memo else None, + "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, + "수량": _qty(one.get("수량"), where), + } + ) + mats, codes = [], set() + for at, one in enumerate(doc.get("자재") or [], start=1): + where = f"자재 {at}째" + code = str(one.get("코드") or "") if isinstance(one, dict) else "" + try: + row = whole.get(code) if code[:2] == mm.MARKET else None + except mf.FormulaError: + row = None + if row is None: + raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」") + if code in codes: + raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」") + codes.add(code) + mats.append( + { + "코드": code, + **{k: row.get(k) or "" for k in _MAT_COLS[1:]}, + "수량": _qty(one.get("수량"), where), + } + ) + out = { + "종류": "일위대가", + "판": doc.get("판") or 1, + "열": doc.get("열"), + "단위": str(doc["단위"]) if doc.get("단위") else None, + "본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None, + "담은로직": lines, + "자재": mats, + } + if doc.get("일위대가번"): + out["일위대가번"] = doc["일위대가번"] + return out + + +# ── 알약 · 면적 값 ──────────────────────────────────────────────────── +def material_key(code: str) -> str: + return f"{MATERIAL_HEAD}{code}" + + +def pills(doc: dict) -> list[dict[str, Any]]: + """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」.""" + logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + mats = [ + { + "키": material_key(m["코드"]), + "이름": m.get("이름") or m["코드"], + "단위": m.get("단위") or "", + "자재": {k: m.get(k) or "" for k in _MAT_COLS}, + } + for m in doc.get("자재") or [] + ] + return [*logic, *mats] + + +def area_values(area: Any) -> dict[str, Decimal]: + """면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만.""" + out: dict[str, Decimal] = {} + if not isinstance(area, dict): + return out + calc = area.get("계산값") or {} + for sheet in area.get("시트") or []: + cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {} + for link in sheet.get("연결") or []: + at = link.get("칸") + raw = (got.get(at) or {}).get("수") + if raw is None: + raw = (cells.get(at) or {}).get("값") + value = _number(raw) + if value is not None and link.get("키"): + out[str(link["키"])] = value + return out + + +def _area_of(column: Any) -> dict[str, Decimal]: + """지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것.""" + try: + path = store._path("area", str(column or "")) + except store.StoreError: + return {} + return area_values(json.loads(path.read_bytes())) if path.is_file() else {} + + +# ── 계산 ────────────────────────────────────────────────────────────── +def _money(value: Any) -> dict[str, Decimal] | None: + if not isinstance(value, dict): + return None + return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")} + + +def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None: + """반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None.""" + if qty is None: + return None + return {k: base[k] * qty / times for k in (*COSTS, "계")} + + +def _sum(rows: list[dict | None]) -> dict[str, Decimal]: + return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")} + + +def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]: + """(수량, 출처) — 손 · 칸(알약 키) · 없음.""" + if isinstance(hand, dict) and _number(hand.get("손")) is not None: + return _number(hand["손"]), "손" + if key and key in given: + return given[key], "칸" + return None, "없음" + + +def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: + """기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시 + (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비).""" + have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} + hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []} + mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} + zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) + lines, mats, bases, reflects = [], [], [], [] + for line in result.get("줄") or []: + qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have) + base = _money(line.get("기준")) + done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None + bases.append(base) + reflects.append(done) + lines.append({**line, "수량": qty, "수량출처": source, "반영": done}) + for one in result.get("자재") or []: + qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) + price = _number(one.get("기준단가")) + cost = price * qty if price is not None and qty is not None else None + bases.append({**zero, "재료비": price, "계": price} if price is not None else None) + reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) + mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) + return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) + + +def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: + """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — + 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" + whole = m01.loaded()[1] + by_logic = { + code: pill + for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + for code in pill["로직"] + } + lines = [] + for one in doc.get("담은로직") or []: + code = one["로직"] + pill = by_logic.get(code) or {} + line = { + "로직": code, + "알약": pill.get("키"), + "분모": pill.get("단위"), + "배": pill.get("배") or 1, + } + try: + logic = whole.logic(code) + auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} + got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})}) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__}) + continue + if "결과" in got: # 돈 아닌 로직 — 반영 없음 + lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) + continue + lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) + mats = [] + for one in doc.get("자재") or []: + try: + price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) + except mf.FormulaError as e: + price, why = None, str(e) + else: + why = None if price is not None else "단가 없음" + mats.append( + {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} + ) + have = _area_of(doc.get("열")) if given is None else given + return settle(_json({"줄": lines, "자재": mats}), doc, have) + + +def _json(value: Any) -> Any: + """Decimal → JSON 수(정수면 int).""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_json(v) for v in value] + if isinstance(value, dict): + return {k: _json(v) for k, v in value.items()} + return value + + +def server(doc: Any) -> dict[str, Any]: + """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림).""" + got = clean(doc) + return {**got, "계산값": calc(got)} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py new file mode 100644 index 000000000..233b1ae30 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -0,0 +1,64 @@ +"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`. + +문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · +여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌). +⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) + + +class CalcBody(BaseModel): + """`doc` = 저장 전 화면 문서(없으면 저장본) · `수량` = {알약 키: 값}(없으면 저장된 면적산출).""" + + doc: dict[str, Any] | None = None + 수량: dict[str, Any] | None = None + + +def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]: + got = sent if sent is not None else store.read("labor", column)["문서"] + return labor.clean({**got, "열": column}) + + +def _calc(column: str, body: CalcBody) -> dict[str, Any]: + doc = _doc(column, body.doc) + return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)} + + +def _pills(column: str) -> dict[str, Any]: + return {"열": column, "알약": labor.pills(_doc(column, None))} + + +@router.post("/layers/{layer}/labor/{column}/calc") +def calc_labor( + layer: Layer, + column: str, + body: CalcBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """줄마다 기준 · 반영 · 합 + 알약(저장 안 함 · 화면은 최신 응답만).""" + return _call(_root(session, layer, owner), _calc, column, body) + + +@router.get("/layers/{layer}/labor/{column}/pills") +def labor_pills( + layer: Layer, + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본).""" + return _call(_root(session, layer, owner), _pills, column) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 90f0dacd7..dba53b49a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -151,8 +151,7 @@ def copy_item( have = store.read(kind, new_id)["문서"] body = {k: v for k, v in got.items() if k != "계산값"} body = {**body, "열": new_id, number: have.get(number)} - if kind in store.SHEETS: - body = store._server_values(body) + body = store._server(kind, body) with store._LOCK: atomic_write_json(store._path(kind, new_id), body) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts new file mode 100644 index 000000000..dcff3da3c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -0,0 +1,113 @@ +/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함. + * + * 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 — + * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. + * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 + * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */ + +export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; +export type Money = Record<(typeof MONEY_KEYS)[number], number>; +export type QtySource = "손" | "칸" | "없음"; +/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */ +export type Qty = { 손: number } | null; + +export interface CalcLine { + 로직: string; + 알약?: string | null; + 분모?: string | null; + 배: number; + 기준: Money | null; + 수량?: number | null; + 수량출처?: QtySource; + 반영?: Money | null; + 까닭?: string | null; + 결과?: unknown; +} + +export interface CalcMaterial { + 코드: string; + 알약?: string | null; + 기준단가: number | null; + 수량?: number | null; + 수량출처?: QtySource; + 반영?: number | null; + 까닭?: string | null; +} + +export interface LaborCalc { + 줄: CalcLine[]; + 자재: CalcMaterial[]; + 기준합?: Money; + 반영합?: Money; +} + +/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ +export interface LaborQtyDoc { + 담은로직?: { 로직: string; 수량?: Qty }[]; + 자재?: { 코드: string; 수량?: Qty }[]; +} + +function num(raw: unknown): number | null { + if (raw === null || raw === undefined || typeof raw === "boolean") return null; + const n = Number(String(raw).replace(/,/g, "").trim()); + return String(raw).trim() !== "" && Number.isFinite(n) ? n : null; +} + +function zero(): Money { + return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 }; +} + +function pick( + hand: Qty | undefined, + key: string | null | undefined, + given: Record, +): [number | null, QtySource] { + const own = hand ? num(hand.손) : null; + if (own !== null) return [own, "손"]; + if (key && key in given) return [given[key], "칸"]; + return [null, "없음"]; +} + +/** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */ +export function reflect(base: Money, qty: number | null, times: number): Money | null { + if (qty === null) return null; + const out = zero(); + for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times; + return out; +} + +/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */ +export function settle( + result: LaborCalc, + doc: LaborQtyDoc, + given: Record, +): LaborCalc { + const have: Record = {}; + for (const [k, raw] of Object.entries(given)) { + const v = num(raw); + if (v !== null) have[k] = v; + } + const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null])); + const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); + const base = zero(); + const done = zero(); + const add = (sum: Money, m: Money | null): void => { + if (m) for (const k of MONEY_KEYS) sum[k] += m[k]; + }; + const 줄 = result.줄.map((line) => { + const [qty, source] = pick(hands.get(line.로직), line.알약, have); + const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null; + add(base, line.기준); + add(done, own); + return { ...line, 수량: qty, 수량출처: source, 반영: own }; + }); + const 자재 = result.자재.map((one) => { + const [qty, source] = pick(mhands.get(one.코드), one.알약, have); + const price = num(one.기준단가); + const cost = price !== null && qty !== null ? price * qty : null; + if (price !== null) add(base, { ...zero(), 재료비: price, 계: price }); + if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost }); + return { ...one, 수량: qty, 수량출처: source, 반영: cost }; + }); + return { 줄, 자재, 기준합: base, 반영합: done }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index ced3a7fc7..0ff11389e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -65,12 +65,13 @@ IN_USE = { # (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면 PARTS = ( ("structure", "도번", "SD", None), + ("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장) ("area", "면적번", "SA", "면적산출"), ("material", "재료번", "SM", "재료산출"), ) _KEY = {kind: key for kind, key, _, _ in PARTS} SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음 -_CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$") +_CODE = re.compile(r"^S[DAMU](\d{8}|-\d{6})$") _OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음 _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties @@ -155,7 +156,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이 만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). 통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 - (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘. + (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는 + `M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도). 번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음). 표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이 원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로). @@ -166,8 +168,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> origins = _take_origins(doc) if kind == "table" else {} if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) - if kind in SHEETS: - doc = _server_values(doc) + doc = _server(kind, doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): @@ -179,6 +180,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> doc = {**doc, _KEY[kind]: number} if number else doc atomic_write_json(path, doc) info = _info(kind, path) + if kind == "area": + _refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도 if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: @@ -282,6 +285,17 @@ def _retired() -> list[str]: def _blank(kind: str, column: str) -> dict[str, Any]: """빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 / 면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음).""" + if kind == "labor": + return { + "종류": "일위대가", + "판": 1, + "열": column, + "단위": None, + "본뜬": None, + "담은로직": [], + "자재": [], + "계산값": {}, + } if kind == "structure": return { "양식": "구조물도면", @@ -311,9 +325,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool: base = _blank(kind, column) if source and _path(kind, source).is_file(): got = json.loads(_path(kind, source).read_bytes()) - base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column} - if kind in SHEETS: - base = _server_values(base) + base = _server(kind, {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}) with _LOCK: if path.is_file(): return False @@ -343,13 +355,13 @@ def _take_origins(doc: Any) -> dict[str, str]: def create_structure(column: str) -> dict[str, Any]: """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409. - 면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리).""" + 일위대가 · 면적산출 · 재료산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리).""" if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") if not _ensure("structure", column): raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") - for kind in SHEETS: - _ensure(kind, column) + for kind in _KEY: + _ensure(kind, column) # 도면은 위에서 만듦 · 있는 것은 그대로 return read("structure", column) @@ -360,8 +372,7 @@ def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} - if kind in SHEETS: - doc = _server_values({k: v for k, v in doc.items() if k != "계산값"}) + doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}) with _LOCK: atomic_write_json(_path(kind, column), doc) return _info(kind, _path(kind, column)) @@ -457,6 +468,30 @@ def renumber() -> dict[str, list[str]]: return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made} +def _server(kind: str, doc: Any) -> Any: + """서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`).""" + if kind in SHEETS: + return _server_values(doc) + if kind == "labor": + from M02_MasterTemplete import M02_Labor_Doc as labor + + return labor.server(doc) + return doc + + +def _refresh_labor(column: str) -> None: + """그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠.""" + path = _path("labor", column) + if not path.is_file(): + return + try: + doc = _server("labor", json.loads(path.read_bytes())) + except StoreError: + return + with _LOCK: + atomic_write_json(path, doc) + + def _server_values(doc: Any) -> dict[str, Any]: try: return spreadsheet.with_server_values(doc) diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index f56c48822..6080bb5b8 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -27,7 +27,8 @@ from config import config_system LAYERS = ("system", "company", "personal", "project") # area = 면적산출 · material = 재료산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) -KINDS = ("table", "drawing", "structure", "area", "material") +# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`) +KINDS = ("table", "drawing", "structure", "labor", "area", "material") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -168,6 +169,7 @@ _SKELETON = { "table": ("열",), "drawing": ("entities",), "structure": ("도면.entities",), + "labor": ("담은로직",), "area": ("시트",), "material": ("시트",), } diff --git a/main.py b/main.py index bd3266c1c..ed3216512 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router +from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router @@ -649,6 +650,7 @@ app.include_router(m02_layers_router, dependencies=protected) app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) +app.include_router(m02_labor_router, dependencies=protected) # 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서. app.include_router(m02_project_items_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/helper_m02_labor_calc.cjs b/resources/tester/helper_m02_labor_calc.cjs new file mode 100644 index 000000000..939a86397 --- /dev/null +++ b/resources/tester/helper_m02_labor_calc.cjs @@ -0,0 +1,21 @@ +/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "M02_MasterTemplete", + "M02_MasterTemplete_Labor_Calc.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값))); diff --git a/resources/tester/test_m02_item_keys.py b/resources/tester/test_m02_item_keys.py index f6c6a82ee..7ac0949b3 100644 --- a/resources/tester/test_m02_item_keys.py +++ b/resources/tester/test_m02_item_keys.py @@ -26,7 +26,7 @@ from resources.tester.test_m02_structure import write_sample_table from resources.tester.test_m02_tree import Tree, _app ROOT = Path(__file__).resolve().parents[2] -NEW = re.compile(r"^S[DAM]-\d{6}$") +NEW = re.compile(r"^S[DAMU]-\d{6}$") @pytest.fixture() @@ -164,7 +164,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): doc = json.loads(path.read_bytes()) path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8") old = {cid: _numbers(system, cid) for cid in store._column_ids()} - assert old["rv"] == {"SD01010100", "SA01010100", "SM01010100"} + assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100", "SM01010100"} first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1 with store.at(system): got = store.read("table", store.STRUCTURE_TABLE) @@ -191,7 +191,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"}) store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음 - assert _numbers(system, "y") == {f"SD{last}", f"SA{last}", f"SM{last}"} + assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}", f"SM{last}"} def test_층마다_번호는_따로_파일은_열_id_로(world): diff --git a/resources/tester/test_m02_labor_calc_mirror.py b/resources/tester/test_m02_labor_calc_mirror.py new file mode 100644 index 000000000..ab8a53680 --- /dev/null +++ b/resources/tester/test_m02_labor_calc_mirror.py @@ -0,0 +1,71 @@ +"""일위대가 수량 · 반영 · 합 거울 시험 — `M02_Labor_Doc.settle`(파이썬) ↔ +`M02_MasterTemplete_Labor_Calc.ts` `settle`(TS · Node) 가 같은 답(CLAUDE.md 5장 ①). + +기준은 서버가 한 번 셈(`calc`) · 그 답에 다른 알약 값을 넣어 둘이 같은지 봄. +""" + +import json +import os +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEN, THOUSAND, PLAIN, MAT = "GC000421", "GF000017", "GC000422", "MT000008" + + +def _ts(answer: dict, doc: dict, values: dict) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], + input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _same(a, b) -> None: + if isinstance(a, dict): + assert set(a) == set(b) + for k in a: + _same(a[k], b[k]) + elif isinstance(a, list): + assert len(a) == len(b) + for x, y in zip(a, b): + _same(x, y) + elif isinstance(a, (int, float)) and not isinstance(a, bool): + assert b == pytest.approx(a, rel=1e-9) + else: + assert a == b + + +@pytest.mark.parametrize( + "values", + [ + {}, # 수량 없음 — 반영 null · 손만 + {"사석 수상고르기|㎡": 2.5, f"자재|{MAT}": "3"}, + {"사석 수상고르기|㎡": "1,200", "나무식재|본": 7, f"자재|{MAT}": "글"}, # 쉼표 · 수 아님 + ], +) +def test_파이썬과_TS_가_같은_답(values): + doc = labor.clean( + { + "열": "rv", + "담은로직": [ + {"로직": TEN, "수량": None}, + {"로직": THOUSAND, "수량": {"손": 4}}, + {"로직": PLAIN, "수량": None}, + ], + "자재": [{"코드": MAT, "수량": None}], + } + ) + answer = labor.calc(doc, {}) + keys = {line["알약"] for line in answer["줄"]} + assert {"사석 수상고르기|㎡", "나무식재|본"} <= keys # 알약 키가 시험 값과 맞음 + py = labor.settle(answer, doc, values) + _same(py, _ts(answer, doc, values)) + assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시 diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py new file mode 100644 index 000000000..d4ae1e3f8 --- /dev/null +++ b/resources/tester/test_m02_labor_doc.py @@ -0,0 +1,213 @@ +"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값. + +로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에. +""" + +import json + +import pytest + +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" # 원/10㎡ — 배 10 +THOUSAND = "GF000017" # 원/1,000본 — 배 1000 +PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직 +STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율 +MAT = "MT000008" # 자재품목 · 물가정보 83000 + + +def _doc(**extra): + return {"열": "rv", "담은로직": [], "자재": [], **extra} + + +def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것(): + got = labor.clean( + _doc( + 담은로직=[ + {"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None} + ], + 자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}], + 단위="개소", + ) + ) + assert got["담은로직"] == [ + {"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None} + ] + assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2} + assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None) + + +@pytest.mark.parametrize( + "bad", + [ + _doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번 + _doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직 + _doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님 + _doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림 + _doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님 + _doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번 + "문서 아님", + ], +) +def test_틀린_문서는_400(bad): + with pytest.raises(store.StoreError) as e: + labor.clean(bad) + assert e.value.status == 400 + + +def test_빈_일위대가도_됨(): + got = labor.server(_doc()) + assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0 + + +def test_PER_천_단위_쉼표는_배(): + pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"] + assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10} + + +def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음(): + doc = labor.clean( + _doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}]) + ) + got = labor.calc(doc, {}) + ten, plain = got["줄"] + assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손") + for k in ("노무비", "재료비", "경비", "계"): + assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10) + assert plain["기준"] is None and plain["반영"] is None and "결과" in plain + assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"]) + + +def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): + doc = labor.clean( + _doc( + 담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}], + 자재=[{"코드": MAT, "수량": None}], + ) + ) + keys = {p["키"] for p in labor.pills(doc)} + ten_key = next(k for k in keys if k.endswith("|㎡")) + thousand_key = next(k for k in keys if k.endswith("|본")) + assert f"자재|{MAT}" in keys + got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"}) + ten, thousand = got["줄"] + assert (ten["수량"], ten["수량출처"]) == (2.5, "칸") + assert (thousand["수량"], thousand["수량출처"]) == (1, "손") + mat = got["자재"][0] + assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000) + nothing = labor.calc(doc, {})["줄"][0] + assert (nothing["수량출처"], nothing["반영"]) == ("없음", None) + + +def test_면적_값_식_칸은_계산값_그_밖은_칸_값(): + area = { + "시트": [ + { + "id": "s1", + "칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}}, + "연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}], + } + ], + "계산값": {"s1": {"D2": {"수": "7.5"}}}, + } + got = labor.area_values(area) + assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5} + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_구조물_만들면_빈_일위대가_같은_뒷자리(system): + doc = store.read("labor", "rv")["문서"] + assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:] + assert doc["담은로직"] == [] and doc["자재"] == [] + + +def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system): + have = store.read("labor", "rv") + sent = { + **have["문서"], + "일위대가번": "SU-999999", + "담은로직": [{"로직": TEN, "수량": None}], + "계산값": {"브라우저": "값"}, + } + store.write("labor", "rv", have["판"], sent) + doc = store.read("labor", "rv")["문서"] + assert doc["일위대가번"] == have["문서"]["일위대가번"] + assert doc["계산값"]["줄"][0]["수량출처"] == "없음" + pill = labor.pills(doc)[0]["키"] + area = store.read("area", "rv") + sheet = area["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", "rv", area["판"], area["문서"]) + line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0] + assert (line["수량"], line["수량출처"]) == (5, "칸") + assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10) + + +def test_틀린_일위대가_저장은_400_파일_그대로(system): + have = store.read("labor", "rv") + with pytest.raises(store.StoreError) as e: + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]}) + assert e.value.status == 400 + assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == [] + + +# ── 길(`M02_Labor_Router`) ───────────────────────────────────────────── +@pytest.fixture() +def client(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from config import config_system + from M02_MasterTemplete import M02_Labor_Router as labor_router + from M02_MasterTemplete import M02_Template_Layers as layers + + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + app = FastAPI() + app.include_router(labor_router.router) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, + "company_id": 7, + "role": "USER", + "is_master": False, + } + return TestClient(app) + + +def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client): + url = "/api/m02/layers/system/labor/rv/calc" + sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]} + got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json() + assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000) + assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"} + saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가 + assert saved["줄"] == [] and saved["알약"] == [] + assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []} + + +def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client): + url = "/api/m02/layers/{}/labor/{}/calc" + bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}} + assert client.post(url.format("system", "rv"), json=bad).status_code == 400 + assert client.post(url.format("system", "없는열"), json={}).status_code == 404 + other = client.post(url.format("personal", "rv") + "?owner=7/99", json={}) + assert other.status_code == 403 diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index c532c67ca..af31c66f7 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -64,8 +64,13 @@ def client(tmp_path, monkeypatch): def _num(code: str) -> dict: - """세 번호 — 같은 8자리.""" - return {"도번": f"SD{code}", "면적번": f"SA{code}", "재료번": f"SM{code}"} + """열 번호 — 같은 뒷자리(일위대가번 SU · 39장).""" + return { + "도번": f"SD{code}", + "일위대가번": f"SU{code}", + "면적번": f"SA{code}", + "재료번": f"SM{code}", + } def test_새로_도번_자동_409_없는_열(client): diff --git a/resources/tester/test_m02_structure_combo.py b/resources/tester/test_m02_structure_combo.py index 4333b2140..68022a738 100644 --- a/resources/tester/test_m02_structure_combo.py +++ b/resources/tester/test_m02_structure_combo.py @@ -86,7 +86,9 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지 assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True) # 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬 numbers = client.get("/api/m02/structures/numbers").json() - assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"] + assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번", "재료번")} == numbers[ + "new1" + ] assert rows["new1"]["도번"].startswith("SD") # 또 저장해도(바뀐 것 없음) 키 · 다음 그대로 From f8d024fc28172cb38984a7eb711bf0c5e9a2af87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:44:31 +0900 Subject: [PATCH 21/36] =?UTF-8?q?refactor(M02):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EB=82=98=EB=AC=B4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=EC=A7=80=EC=9B=80=20=E2=80=94?= =?UTF-8?q?=20/tree=20=C2=B7=20/tree-many=20=C2=B7=20/paste(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Combo_Store 나무 고침 함수(_apply · edit_tree(_many) · commit · _write_master_folders · _set_path) 지움 · 목록 폴더는 읽기만 - Combo_Copy.py(층 사이 붙여넣기) 지움 · Labor_Api 나무 · 붙여넣기 함수 지움 - 옛 나무 · 붙여넣기 시험을 「길 없음」 검사로 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Combo_Copy.py | 98 ---------- .../M02_MasterTemplete_Combo_Store.py | 135 +------------- .../M02_MasterTemplete_Labor_Api.ts | 39 ---- .../M02_MasterTemplete_Router_Combos.py | 66 ------- resources/tester/test_m02_combo_layers.py | 170 +----------------- 5 files changed, 10 insertions(+), 498 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py deleted file mode 100644 index 85b5d499f..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py +++ /dev/null @@ -1,98 +0,0 @@ -"""일위대가 조합 층 사이 복사(PLAN 30-3) — 읽을 수 있는 층의 조합 · 폴더를 쓸 수 있는 층에 붙여넣기. - -정함(30장): 복사본 = 대상 층의 새 키(마스터 UA · 회사 UC · 개인 UP) · 본뜬키 = 원본 키 · -같은 이름이면 「이름 (2)」 · 담은 로직 그대로 · 폴더 복사는 안의 조합 · 빈 폴더까지. -붙는 자리 = `경로`(대상 폴더 · 깊이 2 안) — 고른 조합은 그 폴더에 · 고른 폴더는 그 아래 한 단으로. -마스터에 붙이면 M01 저장 길(대장 UA) · 본뜬키는 UA 만(마스터 틀) — 다른 층 원본이면 비움. -권한은 라우터 몫(원본 층 읽기 · 대상 층 쓰기) · 대상 층 = `store.at(대상)`. -""" - -from __future__ import annotations - -from typing import Any - -from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos -from M02_MasterTemplete import M02_MasterTemplete_Store as store - - -def _free_name(name: str, taken: set[str]) -> str: - out, n = name, 2 - while out in taken: - out, n = f"{name} ({n})", n + 1 - taken.add(out) - return out - - -def _paths(value: Any, what: str) -> list[list[str]]: - if not isinstance(value, list) or not all(isinstance(p, list) for p in value): - raise store.StoreError(422, f"「{what}」 이 폴더 경로 목록이 아님") - return [[str(x) for x in p] for p in value] - - -def picked(source: dict, keys: list[str], folders: list[list[str]], where: list[str]): - """(붙일 조합 [(원본 줄, 새 경로)], 새 빈 폴더) — 깊이 2 넘으면 422 · 없는 키 · 폴더 404.""" - have = combos.folders_of(source) - rows = {str(r.get("키")): r for r in source.get("줄") or []} - out: dict[str, tuple[dict, list[str]]] = {} - made: list[list[str]] = [] - for folder in folders: - if folder not in have: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(folder)}」") - cut = len(folder) - 1 # 고른 폴더부터 대상 아래로 - made += [where + f[cut:] for f in have if f[: len(folder)] == folder] - for key, row in rows.items(): - path = combos.path_of(row) - if path[: len(folder)] == folder: - out[key] = (row, where + path[cut:]) - for key in keys: - if key not in rows: - raise store.StoreError(404, f"없는 조합 「{key}」") - out.setdefault(key, (rows[key], list(where))) - if any(len(p) > combos.DEPTH for p in [*made, *(p for _, p in out.values())]): - raise store.StoreError(422, f"폴더는 {combos.DEPTH} 단까지 — 더 얕은 폴더에 붙여 넣음") - return list(out.values()), made - - -def paste(source: dict, body: dict) -> dict[str, Any]: - """`source` = 원본 층 표(부르는 쪽이 원본 층에서 읽음) · `body` = `{키?, 폴더?, 경로?, 판}` → - `{…대상 층 목록, 새키: [원본 → 새 키]}`.""" - keys = [str(k) for k in body.get("키") or []] - folders = _paths(body.get("폴더") or [], "폴더") - where = [str(x) for x in body.get("경로") or []] - if not keys and not folders: - raise store.StoreError(422, "붙여 넣을 조합 · 폴더가 없음") - doc, have = combos._load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [combos.FILE], "판": have}) - if where and where not in combos.folders_of(doc): - raise store.StoreError(404, f"없는 폴더 「{' › '.join(where)}」") - items, made = picked(source, keys, folders, where) - taken = {str(r.get("이름") or "").strip() for r in doc["줄"]} - master = combos.heads()[-1] == combos.MASTER - rows = [] - for row, path in items: - one = {k: row.get(k) for k in combos.mcb.COLS if k != "키"} - one["이름"] = _free_name(str(row.get("이름") or "").strip(), taken) - one["본뜬키"] = str(row.get("키")) - if master and not one["본뜬키"].startswith(combos.MASTER): - one["본뜬키"] = None # 마스터 틀은 UA 계보만 - combos._set_path(one, path) - rows.append(one) - folders_after = combos.folders_of({**doc, "폴더": [*combos.folders_of(doc), *made]}) - if master: - new = [combos.create(one)["키"] for one in rows] # M01 저장 길(검사 · 대장) — 하나씩 - _, version = combos._load() - combos._write_master_folders(folders_after, version) - else: - number = int(doc.get("다음") or 1) - fixed = [] - for at, one in enumerate(rows): - fixed.append(combos._clean(one, f"{combos.heads()[-1]}{number + at:06d}")) - new_doc = {**doc, "다음": number + len(fixed), "폴더": folders_after} - new_doc["줄"] = [*doc["줄"], *fixed] - for one in fixed: - combos._check(new_doc, one) - combos._save(new_doc, have) - new = [one["키"] for one in fixed] - pairs = [{"원본": str(row.get("키")), "새키": key} for (row, _), key in zip(items, new)] - return {**combos.listing(), "새키": pairs} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 409fb154c..ef0abae0d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -7,13 +7,12 @@ 개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######` 회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀). 지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫. -나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함). +목록 `폴더` = [구분] · [구분, 상세구분](읽기만 · 나무 고침 · 붙여넣기 길은 39-2 에서 지움). """ from __future__ import annotations import json -import os import re from datetime import date from pathlib import Path @@ -127,18 +126,13 @@ def _find(doc: dict, key: str) -> dict[str, Any]: return row -# ── 나무 폴더 ───────────────────────────────────────────────────────── +# ── 폴더(읽기만) ─────────────────────────────────────────────────────── def path_of(row: dict) -> list[str]: """조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리.""" a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분")) return [a, b] if a and b else [a] if a else [] -def _set_path(row: dict, path: list[str]) -> None: - row["구분"] = path[0] if path else None - row["상세구분"] = path[1] if len(path) > 1 else None - - def folders_of(doc: dict) -> list[list[str]]: """폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지.""" out: list[list[str]] = [] @@ -312,128 +306,3 @@ def delete(key: str, version: str) -> dict[str, Any]: _find(doc, key) _refuse_used([key]) return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)} - - -# ── 나무 고침 ───────────────────────────────────────────────────────── -def _moved(path: list[str], old: list[str], new: list[str]) -> list[str]: - return new + path[len(old) :] if path[: len(old)] == old else path - - -def _folder(folders: list[list[str]], path: list[str] | None, what: str) -> list[str]: - path = [str(x) for x in path or []] - if not path or path not in folders: - raise store.StoreError(404, f"{what} 폴더 「{' › '.join(path)}」 없음") - return path - - -def _fresh(name: Any) -> str: - text = str(name or "").strip() - if not text or "›" in text or len(text) > mcb.MAX_이름: - raise store.StoreError(422, "폴더 이름 — 빈 이름 · 「›」 · 너무 긴 이름은 못 씀") - return text - - -def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: - """나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫.""" - op, key = body.get("op"), body.get("키") - folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []] - gone: list[str] = [] - target = [str(x) for x in body.get("새경로") or []] - if op in ("move", "add-folder") and target and target not in folders: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(target)}」") - if op == "add-folder": - made = [*[str(x) for x in body.get("경로") or []], _fresh(body.get("이름"))] - if len(made) > DEPTH or (made[:-1] and made[:-1] not in folders): - raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 있는 폴더 아래에만") - if made in folders: - raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」") - folders.append(made) - elif op == "rename" and key: - _find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름")) - elif op == "move" and key: - _set_path(_find({"줄": rows}, str(key)), target) - elif op == "delete" and key: - _find({"줄": rows}, str(key)) - gone = [str(key)] - elif op in ("rename", "move", "delete"): - old = _folder(folders, body.get("경로"), "고칠") - if op == "delete": - gone = [str(r["키"]) for r in rows if path_of(r)[: len(old)] == old] - folders = [f for f in folders if f[: len(old)] != old] - else: - new = [*old[:-1], _fresh(body.get("이름"))] if op == "rename" else [*target, old[-1]] - deepest = max(len(f) for f in folders if f[: len(old)] == old) - if new[: len(old)] == old or deepest - len(old) + len(new) > DEPTH: - raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 제 안으로 못 옮김") - if new in folders: - raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(new)}」") - folders = [_moved(f, old, new) for f in folders] - for r in rows: - if path_of(r)[: len(old)] == old: - _set_path(r, _moved(path_of(r), old, new)) - else: - raise store.StoreError( - 422, f"모르는 나무 고침 「{op}」 — add-folder · rename · move · delete" - ) - rows = [r for r in rows if str(r.get("키")) not in gone] - return {**doc, "폴더": folders, "줄": rows}, gone - - -def edit_tree(body: dict) -> dict[str, Any]: - """폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · - 조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422.""" - return edit_tree_many({"판": body.get("판"), "ops": [body]}) - - -def edit_tree_many(body: dict) -> dict[str, Any]: - """나무 고침 여럿을 한 번에(PLAN 30-3 · 여러 개 옮기기 · 지우기) — `{판, ops: [고침 …]}` · - 차례대로 적용 · 하나라도 막히면 아무것도 안 씀 · 저장 한 번.""" - doc, have = _load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": have}) - ops = body.get("ops") - if not isinstance(ops, list) or not ops: - raise store.StoreError(422, "나무 고침 목록(ops)이 비었음") - new, gone = doc, [] - for op in ops: - new, got = _apply(new, op if isinstance(op, dict) else {}) - gone += got - _refuse_used(gone) - renamed = [str(op["키"]) for op in ops if op.get("op") == "rename" and op.get("키")] - commit(doc, have, new, gone, renamed) - return {**listing(), "지운조합": gone} - - -def commit(doc: dict, have: str, new: dict, gone: list[str], checked: list[str] = ()) -> None: - """고친 표 한 벌 저장 — 회사 · 개인 = 파일 한 번(`checked` 조합은 이름 검사) · - 마스터 = 줄은 M01 저장 길 · 머리 `폴더` 따로.""" - if heads()[-1] != MASTER: - for key in checked: - if key in {str(r.get("키")) for r in new["줄"]}: - if errors := _name_errors(new, _find(new, key)): - raise store.StoreError(422, {"errors": errors}) - _save(new, have) - return - was = {str(r["키"]): r for r in doc["줄"]} - changes = [{"op": "delete", "key": k} for k in gone] + [ - {"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r - ] - version = _m01(combo_store._write, changes, have)["version"] if changes else have - _write_master_folders(new["폴더"], version) - - -def _write_master_folders(folders: list[list[str]], version: str) -> None: - """마스터 조합 파일 머리 `폴더` — 줄은 안 건드림(줄 고침은 M01 저장 길).""" - path = Path(m01.FOLDER) / FILE - with m01._LOCK: - doc, have = m01.read(FILE) - if have != version: - raise store.StoreError(409, {"stale": [FILE], "판": have}) - if doc.get("폴더") == folders: - return - head = {k: v for k, v in doc.items() if k != "줄"} - text = m01.dump({**head, "폴더": folders, "줄": doc.get("줄") or []}) - temporary = path.with_name(f".{FILE}.tmp") - temporary.write_text(text, encoding="utf-8", newline="\n") - os.replace(temporary, path) - m01.cache.stale([FILE]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 4dfb87e84..64dd9784c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -95,15 +95,6 @@ export interface ComboLayerList extends ComboList { layer: LaborScope["layer"]; owner: string | null; } -export interface ComboTreeEdit { - op: "add-folder" | "rename" | "move" | "delete"; - 경로?: string[]; - 이름?: string; - 새경로?: string[]; - 키?: string; - 판: string; -} - async function request(url: string, method = "GET", body?: unknown): Promise { const response = await fetch(`${API_BASE_URL}/m02${url}`, { method, @@ -197,36 +188,6 @@ export const deleteCombo = ( scope: LaborScope = SYSTEM_SCOPE, ): Promise => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE"); -/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */ -export const editComboTree = ( - body: ComboTreeEdit, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/tree"), "POST", body); - -/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */ -export const editComboTreeMany = ( - ops: Omit[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/tree-many"), "POST", { 판, ops }); - -/** 층 사이 복사(PLAN 30-3) — `from` 층의 조합 · 폴더를 이 층 `경로` 폴더에 새 키로 */ -export const pasteCombos = ( - from: LaborScope, - pick: { 키?: string[]; 폴더?: string[][] }, - 경로: string[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/paste"), "POST", { - from_layer: from.layer, - from_owner: from.owner, - ...pick, - 경로, - 판, - }); - /** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ export const previewCombo = ( combo: Combo | string, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index ad8fb8ad4..293ae2054 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -19,7 +19,6 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Layer_Access as access -from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -31,36 +30,6 @@ class ComboBody(BaseModel): 판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409 -class ComboTreeBody(BaseModel): - """`{op: add-folder|rename|move|delete, 경로?, 이름?, 새경로?, 키?, 판}` — 폴더 깊이 2.""" - - op: str - 경로: list[str] | None = None - 이름: str | None = None - 새경로: list[str] | None = None - 키: str | None = None - 판: str - - -class TreeManyBody(BaseModel): - """여러 개 옮기기 · 지우기(PLAN 30-3) — `ops` = 나무 고침(`ComboTreeBody` 모양) · 저장 한 번.""" - - 판: str - ops: list[dict[str, Any]] - - -class PasteBody(BaseModel): - """층 사이 복사(PLAN 30-3) — 원본 층(`from_layer` · `from_owner`)의 조합 `키` · 폴더 `폴더` → - 이 층 `경로` 폴더에 새 키로. `판` = 이 층 조합 표 판.""" - - from_layer: Layer - from_owner: str | None = None - 키: list[str] = [] - 폴더: list[list[str]] = [] - 경로: list[str] = [] - 판: str = "" - - class PreviewBody(BaseModel): """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" @@ -166,41 +135,6 @@ def delete_practice(판: str = "", session: Session = Depends(verify_session)) - return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) -@router.post("/layers/{layer}/combos/tree") -def post_tree( - layer: Layer, - body: ComboTreeBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree, body.model_dump()) - - -@router.post("/layers/{layer}/combos/tree-many") -def post_tree_many( - layer: Layer, - body: TreeManyBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree_many, body.model_dump()) - - -@router.post("/layers/{layer}/combos/paste") -def post_paste( - layer: Layer, - body: PasteBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """원본 층 읽기 권한 · 이 층 쓰기 권한(M02_Layer_Access) — 마스터 붙이기 = 시스템 관리자.""" - source = _call(_root(session, body.from_layer, body.from_owner), lambda: combos._load()[0]) - folder = _root(session, layer, owner, write=True) - return _call(folder, combo_copy.paste, source, body.model_dump()) - - @router.post("/layers/{layer}/combos/preview") def post_preview( layer: Layer, diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 7796b80d3..66b83195a 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -4,8 +4,7 @@ - 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA. - 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙). - 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409. - - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409). - - 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과. + - 나무 고침 · 층 사이 붙여넣기 길은 없음(PLAN 39-2 · 일위대가는 구조물 열마다). - 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에. - 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음. 정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본. @@ -78,13 +77,6 @@ def _list(client: TestClient, layer: str, **params) -> dict: return res.json() -def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict: - body.setdefault("판", _list(client, layer)["판"]) - res = client.post(URL.format(layer) + "/tree", json=body) - assert res.status_code == want, res.text - return res.json() - - # ── 키 · 자리 ───────────────────────────────────────────────────────── def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world): boss = _as(world, "ADMIN") @@ -135,60 +127,7 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world): assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B -# ── 나무 ───────────────────────────────────────────────────────────── -def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): - me = _as(world) - key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"] - _tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기") - _tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기") - _tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째") - _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기") - _tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"]) - got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"]) - row = next(r for r in got["줄"] if r["키"] == key) - assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기") - got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축") - assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]] - assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축" - _tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"]) - got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[]) - assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음 - row = next(r for r in got["줄"] if r["키"] == key) - assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) - _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") - _new(me, "personal", _combo("다른 것")) - got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌") - assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌" - assert ( - "이미 있음" - in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0] - ) - other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것") - _tree(me, "personal", op="delete", 키=other) - got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) - assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] - - -def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world): - me = _as(world) - key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"] - tree = me.post( - "/api/m02/layers/personal/tree", - json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)}, - ).json() - column = tree["알림"]["열"] - use = me.post( - f"/api/m02/layers/personal/structures/{column}/use-combo", - json={"종류": "일위대가", "키": key}, - ) - assert use.status_code == 200, use.text - got = _tree(me, "personal", 409, op="delete", 경로=["석축"]) - assert got["detail"]["users"] == ["옹벽 찰쌓기"] - res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]}) - assert res.status_code == 409 - assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음 - - +# ── 마스터 사본 ─────────────────────────────────────────────────────── def _판(client: TestClient) -> str: return client.get("/api/m02/layers/personal/tree").json()["판"] @@ -208,28 +147,6 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return folder -def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): - admin = _as(world, "SYSTEM_ADMIN") - before = _list(admin, "system") - first = before["줄"][0]["키"] - got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본") - assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본" - before = _list(admin, "system") - old = next(r["구분"] for r in before["줄"] if r["구분"]) # 정본 폴더 이름에 기대지 않음 - assert [old] in before["폴더"] - _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") - got = _tree(admin, "system", op="rename", 경로=[old], 이름="시험 폴더") - assert ["시험 폴더"] in got["폴더"] and ["빈 폴더"] in got["폴더"] - assert [old] not in got["폴더"] - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert ["빈 폴더"] in doc["폴더"] - assert sum(r["구분"] == "시험 폴더" for r in doc["줄"]) == len( - [r for r in before["줄"] if r["구분"] == old] - ) - assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로 - _tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘") - - # ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ───────────────────────────────── def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world): boss = _as(world, "ADMIN") @@ -316,81 +233,10 @@ def test_담을_로직_읽기는_로그인이면_됨(world): assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 -# ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ───────────────────────────── -def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict: - body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "") - res = client.post(URL.format(layer) + "/paste", json=body, params=params) - assert res.status_code == want, res.text - return res.json() - - -def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world): - user = _as(world) - master = _list(user, "system")["줄"][:3] - keys = [r["키"] for r in master] - got = _paste(user, "personal", {"from_layer": "system", "키": keys}) - assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"] - rows = {r["키"]: r for r in got["줄"]} - for pair, src in zip(got["새키"], master): - one = rows[pair["새키"]] - assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"]) - assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위 - again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]}) - assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)") - _paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음 - _paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403) - _paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404) - stale = {"from_layer": "system", "키": keys, "판": "낡은판"} - assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409 - - -def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world): - admin = _as(world, "ADMIN") - for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])): - _new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1])) - _tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳") - body = {"from_layer": "personal", "폴더": [["석축"]]} - got = _paste(admin, "company", body) - assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"] - assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]] - by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]} - assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기") - _tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳") - deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]} - assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"] - one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]} - got = _paste(admin, "company", one) # 한 단 폴더는 들어감 - assert ["다른 곳", "찰쌓기"] in got["폴더"] - - -def test_여러_개_옮기기_지우기_한_번에(world): - me = _as(world) - keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")] - _tree(me, "personal", op="add-folder", 경로=[], 이름="석축") - 판 = _list(me, "personal")["판"] - ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]] - res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}) - assert res.status_code == 200, res.text - assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None] - bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]} - assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404 - assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀 - 판 = _list(me, "personal")["판"] - ops = [{"op": "delete", "키": k} for k in keys[1:]] - got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json() - assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1] - stale = {"판": "낡은판", "ops": ops} - assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409 - - -def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy): +# ── 나무 길 없음(PLAN 39-2) ──────────────────────────────────────────── +def test_나무_고침_붙여넣기_길은_없음(world): + """PLAN 39-2 — 일위대가 조합 나무 길(`/tree` · `/tree-many` · `/paste`)을 지움.""" admin = _as(world, "SYSTEM_ADMIN") - up = _new(admin, "personal", _combo("개인 것"))["키"] - before = len(_list(admin, "system")["줄"]) - got = _paste(admin, "system", {"from_layer": "personal", "키": [up]}) - new = got["새키"][0]["새키"] - assert new.startswith("UA") and len(got["줄"]) == before + 1 - row = next(r for r in got["줄"] if r["키"] == new) - assert row["본뜬키"] is None and row["이름"] == "개인 것" - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert m01.cm.check_form(combos.FILE, doc) == [] + for tail in ("/tree", "/tree-many", "/paste"): + res = admin.post(URL.format("personal") + tail, json={"판": ""}) + assert res.status_code in (404, 405), tail From 3ad7678e15cb2bc9a2eba85ac53629928487af53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:44:57 +0900 Subject: [PATCH 22/36] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D=20[=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=B6=94=EA=B0=80]=20=E2=80=94=20M01=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=ED=95=98=EB=82=98=EC=94=A9(M02=5FMaterial=5FPick)?= =?UTF-8?q?=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=9E=90=EC=9E=AC=20=EB=91=90?= =?UTF-8?q?=20=EB=B2=88=20=EB=A7=89=EC=9D=8C(39-3=20C5)?= 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_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor_Detail.ts | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d56ecc546..dfddeb889 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -17,7 +17,6 @@ import { fetchLogicInputs, type LaborCalc, type LaborDoc, - type LaborMat, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { @@ -30,6 +29,7 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; +import { pickMaterial } from "./M02_Material_Pick"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -67,8 +67,6 @@ export interface LaborDetailOptions { onChanged: (dirty: boolean) => void; /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ goArea: (() => void) | null; - /** 자재 하나 고르기(`M02_Material_Pick`) — 없으면 [자재 추가] 없음 */ - pickMaterial?: (done: (m: Omit) => void) => void; } export interface LaborDetailHandle { @@ -253,24 +251,21 @@ export function mountLaborDetail( void loadLogics().then(changed); }), }); - const pick = opt.pickMaterial; - const addMat = pick - ? createButton({ - label: tc("Labor_AddMat"), - variant: "filled", - size: "sm", - onClick: () => - pick((m) => { - if (cur.자재.some((x) => x.코드 === m.코드)) - return showToast( - tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), - "error", - ); - cur.자재.push({ ...m, 수량: null }); - changed(); - }), - }) - : null; + const addMat = createButton({ + label: tc("Labor_AddMat"), + variant: "filled", + size: "sm", + onClick: () => + pickMaterial((m) => { + if (cur.자재.some((x) => x.코드 === m.코드)) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }), + }); const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); unit.input.value = cur.단위 ?? ""; From ba5c2e99d4b49423a6759deabacf6f14ba6d45d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:46:30 +0900 Subject: [PATCH 23/36] =?UTF-8?q?feat(M02):=20=EC=98=9B=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=E2=86=92=20?= =?UTF-8?q?=EC=97=B4=EB=A7=88=EB=8B=A4=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=AC=B8=EC=84=9C=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B8=B5=20=EB=8F=8C?= =?UTF-8?q?=EB=A6=BC=20(39-1b=20B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Labor_Migrate: 층마다 열마다 labor 문서 · 여러 열이 쓰던 조합은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 다시 돌려도 같음 · --storage(회사 · 개인 층) · --dry-run - M02_Labor_Doc.from_combo: 옛 조합(실무문서 UA · UC · UP) → 일위대가 문서 몸(본뜬 = 층 · 주인 · 키) - 시스템 층 결과: labor/item1(← UA000008) · item2(빈) · item3(← UA000001) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M02_MasterTemplete/M02_Labor_Doc.py | 22 ++ M02_MasterTemplete/M02_Labor_Migrate.py | 90 ++++++ resources/master_template/labor/item1.json | 340 +++++++++++++++++++++ resources/master_template/labor/item2.json | 11 + resources/master_template/labor/item3.json | 98 ++++++ resources/tester/test_m02_labor_migrate.py | 119 ++++++++ 6 files changed, 680 insertions(+) create mode 100644 M02_MasterTemplete/M02_Labor_Migrate.py create mode 100644 resources/master_template/labor/item1.json create mode 100644 resources/master_template/labor/item2.json create mode 100644 resources/master_template/labor/item3.json create mode 100644 resources/tester/test_m02_labor_migrate.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 8036a0d01..60be6edbe 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -125,6 +125,28 @@ def clean(doc: Any) -> dict[str, Any]: return out +def from_combo(key: str) -> dict[str, Any]: + """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — + 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). + 없는 조합은 404.""" + from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + + row = combos.find(key) + if row is None: + raise store.StoreError(404, f"없는 일위대가 조합 「{key}」") + layer, owner = combos.where(key) or (None, None) + return { + "단위": row.get("단위"), + "본뜬": {"층": layer, "주인": owner, "키": key}, + "담은로직": [ + {"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None} + for one in row.get("담은로직") or [] + if isinstance(one, dict) + ], + "자재": [], + } + + # ── 알약 · 면적 값 ──────────────────────────────────────────────────── def material_key(code: str) -> str: return f"{MATERIAL_HEAD}{code}" diff --git a/M02_MasterTemplete/M02_Labor_Migrate.py b/M02_MasterTemplete/M02_Labor_Migrate.py new file mode 100644 index 000000000..37527b31b --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Migrate.py @@ -0,0 +1,90 @@ +"""옛 일위대가 조합 → 구조물 열마다 일위대가 문서 — 한 번 돌리는 도구(PLAN 39-1b · 설계_정함 1장). + +층마다 구조물 열마다 `labor/<열id>.json`: + - 열 `일위대가` 키가 있으면 그 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · + 수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사 + - 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리) + - 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로 +옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9). + + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층 + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층 + [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 + + +def _filled(doc: dict) -> bool: + return bool(doc.get("담은로직") or doc.get("자재")) + + +def migrate(folder: Path, dry: bool = False) -> dict[str, list[str]]: + """`folder` 층 하나 — `{만듦, 옮김, 그대로, 못함}`(열 id · 못함은 까닭 붙임).""" + out: dict[str, list[str]] = {"만듦": [], "옮김": [], "그대로": [], "못함": []} + with store.at(folder): + for col in store._structure_columns(): + cid, key = col["id"], str(col.get("일위대가") or "") + path = store._path("labor", cid) + have = json.loads(path.read_bytes()) if path.is_file() else None + done = have is not None and (have.get("본뜬") or {}).get("키") == key + if have is not None and (_filled(have) or not key or done): + out["그대로"].append(cid) + continue + if not key: + out["만듦"].append(cid) + if not dry: + store._ensure("labor", cid) + continue + try: + body = labor.from_combo(key) + except store.StoreError as e: + out["못함"].append(f"{cid} · {e.detail}") + continue + out["옮김"].append(f"{cid} ← {key}") + if dry: + continue + store._ensure("labor", cid) # 번호(SU · 같은 뒷자리) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], **body}) + return out + + +def layer_folders(storage: bool) -> list[Path]: + """시스템 층 하나 · 또는 storage 회사 · 개인 층(구조물집계표가 있는 것 · 프로젝트 빼고).""" + if not storage: + return [layers.system_dir()] + root = layers.storage_root() + table = Path("table") / f"{store.STRUCTURE_TABLE}.json" + found = [ + *root.glob(f"*/{layers.TEMPLATES_DIRNAME}"), + *root.glob(f"*/*/{layers.TEMPLATES_DIRNAME}"), + ] + return sorted(p for p in found if (p / table).is_file()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--storage", action="store_true", help="회사 · 개인 층(storage)") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + report: dict[str, Any] = {} + for folder in layer_folders(args.storage): + report[str(folder)] = migrate(folder, args.dry_run) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/resources/master_template/labor/item1.json b/resources/master_template/labor/item1.json new file mode 100644 index 000000000..b9889e9e3 --- /dev/null +++ b/resources/master_template/labor/item1.json @@ -0,0 +1,340 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item1", + "단위": "개소", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000008" + }, + "담은로직": [ + { + "로직": "GC000198", + "메모": "레미콘타설(장비) 무근,소형", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000214", + "메모": "유로폼 설치 및 해체 보통", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000206", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000207", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000218", + "메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000225", + "메모": "깬잡석찰붙임 L3=30cm내외", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000156", + "메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000226", + "메모": "큰돌메쌓기 뒷길이 0.6~0.8m", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000214", + "메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000222", + "메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000677", + "메모": "모르터바르기 1:3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000221", + "메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000088", + "메모": "구조물터파기(토사) 굴착기0.6m3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000113", + "메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000001", + "계산값": { + "줄": [ + { + "로직": "GC000198", + "알약": "레디믹스트콘크리트 타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 58122.782608695656, + "재료비": 0, + "경비": 1162.455652173913, + "계": 59285.23826086956 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000214", + "알약": "유로폼 설치 및 해체|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51524.36363636364, + "재료비": 0, + "경비": 1545.730909090909, + "계": 53070.09454545454 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000206", + "알약": "철근 현장가공|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 217028.66666666666, + "재료비": 0, + "경비": 19532.58, + "계": 236561.24666666667 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000207", + "알약": "철근 현장조립|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 574487.6470588235, + "재료비": 11765, + "경비": 11489.75294117647, + "계": 597742.4 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000218", + "알약": "기초다짐 및 뒷채움|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 12869.246375, + "재료비": 1530.2328689, + "경비": 1315.382, + "계": 15714.8612439 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000225", + "알약": "돌붙임 찰붙임|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 49447.04583333333, + "재료비": 4806.2270256, + "경비": 6425.98, + "계": 60679.25285893333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000156", + "알약": "기계비빔타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 144348.25222222222, + "재료비": 1647.113, + "경비": 378, + "계": 146373.36522222223 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000226", + "알약": "큰돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 10, + "기준": { + "노무비": 819958.01, + "재료비": 132344.5084416, + "경비": 111948.7001, + "계": 1064251.2185416 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000214", + "알약": "야면석 채집|㎡ · ㎥", + "분모": "㎡ · ㎥", + "배": 1, + "기준": { + "노무비": 18927.48, + "재료비": 0, + "경비": 0, + "계": 18927.48 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000222", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51101.38458333333, + "재료비": 6772.4108088, + "경비": 9054.79, + "계": 66928.58539213333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000677", + "알약": "모르타르 배합|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 113564.88, + "재료비": 166136, + "경비": 0, + "계": 279700.88 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000221", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 52917.12, + "재료비": 1469.88, + "경비": 0, + "계": 54387 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000088", + "알약": "터파기(기계) 보통토사|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 843.1458333333334, + "재료비": 627.50883, + "경비": 423.15714285714284, + "계": 1893.811806190476 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000113", + "알약": "되메우기|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2660.6419100706057, + "재료비": 416.1508405797101, + "경비": 391.04953017996496, + "계": 3467.8422808302807 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 2167800.6667278423, + "재료비": 327515.0318154797, + "경비": 163667.5782754784, + "계": 2658983.2768188003 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/master_template/labor/item2.json b/resources/master_template/labor/item2.json new file mode 100644 index 000000000..a59dd165c --- /dev/null +++ b/resources/master_template/labor/item2.json @@ -0,0 +1,11 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item2", + "단위": null, + "본뜬": null, + "담은로직": [], + "자재": [], + "계산값": {}, + "일위대가번": "SU-000002" +} diff --git a/resources/master_template/labor/item3.json b/resources/master_template/labor/item3.json new file mode 100644 index 000000000..b512db52a --- /dev/null +++ b/resources/master_template/labor/item3.json @@ -0,0 +1,98 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item3", + "단위": "식", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000001" + }, + "담은로직": [ + { + "로직": "GF000219", + "메모": "메쌓기 — 돌쌓기 인력", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000119", + "메모": "노체포설 — 굴착기 0.7㎥", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000137", + "메모": "운반 — 중기운반 트레일러(20ton)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000003", + "계산값": { + "줄": [ + { + "로직": "GF000219", + "알약": "돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 79279.668, + "재료비": 0, + "경비": 0, + "계": 79279.668 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000119", + "알약": "노체포설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2433.321308321308, + "재료비": 1077.3082366522367, + "경비": 1012.3273551844981, + "계": 4522.956900158043 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000137", + "알약": "중기운반 트레일러|회", + "분모": "회", + "배": 1, + "기준": { + "노무비": 38181.82530864197, + "재료비": 32481.682622277778, + "경비": 13280.479259259258, + "계": 83943.98719017902 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 119894.81461696328, + "재료비": 33558.99085893002, + "경비": 14292.806614443756, + "계": 167746.61209033706 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/tester/test_m02_labor_migrate.py b/resources/tester/test_m02_labor_migrate.py new file mode 100644 index 000000000..1098822af --- /dev/null +++ b/resources/tester/test_m02_labor_migrate.py @@ -0,0 +1,119 @@ +"""옛 일위대가 조합 → 열마다 일위대가 문서 옮기기(`M02_Labor_Migrate` · PLAN 39-1b). + +시스템 층 = 견본 표(임시 폴더) · 조합은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +import json +from pathlib import Path + +import pytest + +from config import config_system +from M02_MasterTemplete import M02_Labor_Migrate as migrate +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SHARED = "UP42-000001" + + +def _point(folder: Path, keys: dict[str, str]) -> None: + path = folder / "table" / f"{store.STRUCTURE_TABLE}.json" + doc = json.loads(path.read_bytes()) + for col in doc["열"]: + if col["id"] in keys: + col["일위대가"] = keys[col["id"]] + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + _point(system, {"rv": "UA000008", "c1": "UA000008", "pv_b": "UA999999"}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + doc["열"] = [ + {"id": "a", "머리": ["돌", "가"], "꼴": "수", "일위대가": SHARED}, + {"id": "b", "머리": ["돌", "나"], "꼴": "수", "일위대가": SHARED}, + ] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + combo = { + "그룹": "일위대가조합", + "다음": 2, + "줄": [ + { + "키": SHARED, + "이름": "개인 조합", + "단위": "개소", + "담은로직": [{"로직": "GF000219", "메모": "메쌓기"}], + } + ], + } + (personal / combos.FILE).write_text(json.dumps(combo, ensure_ascii=False), encoding="utf-8") + return {"system": system, "personal": personal, "storage": storage} + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_시스템_층_열마다_복사_같은_조합도_따로_없는_조합은_못함(world): + system = world["system"] + got = migrate.migrate(system) + assert got["옮김"] == ["rv ← UA000008", "c1 ← UA000008"] + assert got["못함"] == ["pv_b · 없는 일위대가 조합 「UA999999」"] + rv, c1 = _labor(system, "rv"), _labor(system, "c1") + assert rv["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert rv["담은로직"] == c1["담은로직"] and len(rv["담은로직"]) == 14 + assert all(one["입력값"] == {} and one["수량"] is None for one in rv["담은로직"]) + assert rv["일위대가번"] != c1["일위대가번"] # 열마다 자기 번호 + with store.at(system): # 나중에 만든 도면도 같은 뒷자리 + store.create_structure("rv") + doc = json.loads((system / "structure" / "rv.json").read_bytes()) + assert doc["도번"][2:] == rv["일위대가번"][2:] + assert rv["계산값"]["줄"] # 서버가 계산값까지 + assert _labor(system, "ms")["담은로직"] == [] # 키 없는 열 = 빈 일위대가 + + +def test_개인_층_여러_열이_쓰던_조합은_열마다_본뜬은_그_층(world): + personal = world["personal"] + assert migrate.layer_folders(True) == [personal] + got = migrate.migrate(personal) + assert got["옮김"] == [f"a ← {SHARED}", f"b ← {SHARED}"] + a, b = _labor(personal, "a"), _labor(personal, "b") + assert a["본뜬"] == b["본뜬"] == {"층": "personal", "주인": "7/42", "키": SHARED} + assert (a["단위"], a["담은로직"][0]["메모"]) == ("개소", "메쌓기") + assert a["일위대가번"] != b["일위대가번"] + + +def test_다시_돌려도_같음_사람이_고친_문서는_그대로(world): + system = world["system"] + migrate.migrate(system) + before = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert migrate.migrate(system)["옮김"] == [] + after = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert before == after + with store.at(system): # 사람이 비운 문서도 다시 안 채움 + got = store.read("labor", "rv") + store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": []}) + assert "rv" in migrate.migrate(system)["그대로"] + assert _labor(system, "rv")["담은로직"] == [] + + +def test_미리_보기는_파일을_안_씀(world): + system = world["system"] + got = migrate.migrate(system, dry=True) + assert "rv ← UA000008" in got["옮김"] + assert not list((system / "labor").glob("*.json")) From 1cf32deacac1be913ce8f6782f84fba059f6b8ac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:48:04 +0900 Subject: [PATCH 24/36] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=EC=97=B4=20=EC=A4=84=20=E2=80=94=20=E3=80=8CSU-=E2=80=A6=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=E3=80=8D=20=C2=B7=20=EB=88=84=EB=A6=84=20=3D?= =?UTF-8?q?=20onOpen({kind:=20"labor"})(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 하위 = 구조물 열마다 한 줄(번호 = /structures/numbers 일위대가번 · 없으면 「없음」) - Api_Fetch Kind 에 labor · StructureNumbers 에 일위대가번(39-1a 서버 칸) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Api_Fetch.ts | 7 +++++-- M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts | 16 +++++++--------- resources/tester/test_m02_labor_tree.py | 6 +++++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6ac97e28e..76960523f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -8,8 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · - * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "material"; + * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) · + * 「일위대가」 = labor(열마다 문서 · 39장) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor"; export interface TemplateInfo { 종류: Kind; @@ -153,6 +154,8 @@ export interface StructureNumbers { 도번: string; 면적번: string; 재료번: string; + /** 일위대가 `SU-######`(39장) */ + 일위대가번: string; } /** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7d10e1875..51c9400c1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -48,17 +48,17 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · - * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */ type ColumnKind = "structure" | "labor" | "area" | "material"; const COLUMN_KINDS: { kind: ColumnKind; - key: keyof StructureNumbers | null; + key: keyof StructureNumbers; label: () => string; all: boolean; }[] = [ { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, - { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true }, { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; @@ -262,10 +262,8 @@ export function buildSide(opt: SideOptions): SideHandle { /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { - const { key } = k; - const head = k.all ? [allRow(k.kind as AllKind)] : []; - if (key === null) return body.replaceChildren(...head); - const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 + const { kind, key } = k; + const head = k.all ? [allRow(kind as AllKind)] : []; body.replaceChildren( ...head, ...(columns.length @@ -318,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle { opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: Kind): Promise { + async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 0776b560f..e77d15a6d 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -34,7 +34,11 @@ def test_하위_차례_전체_항목_이름(): kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 - assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds + # 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길 + assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side + api = _text("M02_MasterTemplete_Api_Fetch.ts") + assert '| "labor";' in api and "일위대가번: string;" in api assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side assert 'return void selectAll("labor");' in side # 해시 open=일위대가 From fdb07fa4abedccc79044db1a91794179529b33d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:48:58 +0900 Subject: [PATCH 25/36] =?UTF-8?q?style(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B4=EB=93=9C=20[=EC=95=8C=EC=95=BD]=20?= =?UTF-8?q?=EC=BC=AC=20=EB=8B=A8=EC=B6=94=20=EA=B0=95=EC=A1=B0=EC=83=89=20?= =?UTF-8?q?=E2=80=94=20=EC=BC=AC=20=C2=B7=20=EB=81=94=EC=9D=B4=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=EC=97=90=EC=84=9C=20=EB=B3=B4=EC=9E=84=20(PLAN=2039-4?= =?UTF-8?q?=20D1)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- M02_MasterTemplete/M02_MasterTemplete_Structure.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 57eaacbce..6b54347fd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -252,3 +252,9 @@ .m02-struct__sheet.is-pills-off .ss-links { display: none; } + +/* [알약] 켬 — 눌린 단추를 강조색으로(밝음 · 어두움 같은 토큰) */ +.m02-struct__card-tools .ui-btn[aria-pressed="true"] { + border-color: var(--color-accent); + color: var(--color-accent); +} From 5617f9e891fb894a8af4842a1c4a164f15868c84 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:51:17 +0900 Subject: [PATCH 26/36] =?UTF-8?q?feat(M02):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=EB=B3=B5=EC=82=AC=20=EC=B8=B5=20=EB=84=98?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=E2=86=92=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=3D=20=EC=83=88=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=EB=B2=88=20(39-1b=20B5=20=C2=B7=20B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /layers/{층}/{structures|labor|area|material}/{열}/copy 에 from_layer · from_owner(원본 읽기 권한 · 받는 층 쓰기) · from_layer=practice = 실무문서 조합(UA) → 일위대가 문서(시스템 관리자만 · 일위대가만) - copy_from 원본 층 · 원본 문서 받음 · 면적산출 복사 뒤 그 열 일위대가 계산값 다시 - 구조물 조합 복사(copy_item) · 분류 더하기 = 일위대가 문서를 새 SU 로 복사 · 열 일위대가 조합 키는 안 받음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Items.py | 14 +- .../M02_MasterTemplete_Router_Items.py | 22 ++- .../M02_MasterTemplete_Store.py | 30 +++- resources/tester/test_m02_labor_copy.py | 134 ++++++++++++++++++ resources/tester/test_m02_layers_store.py | 3 +- 5 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_m02_labor_copy.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index dba53b49a..86b99fe92 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -103,8 +103,9 @@ def copy_item( origin: dict[str, Any], path: list[str] | None = None, ) -> dict[str, Any]: - """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · - 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 · + """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 · + 도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 · 재료산출 내용(알약 연결 포함) · + 항목 연결 · 자재 조합 줄(새 UM) 복사 · 새 번호 · 새 SS 키 · 열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 · 붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`.""" @@ -141,7 +142,13 @@ def copy_item( table = store.read("table", store.STRUCTURE_TABLE) doc = table["문서"] new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}) - made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin} + made = { + **json.loads(json.dumps(col)), + "id": new_id, + "일위대가": None, # 정본 = 일위대가 문서(아래 복사) + "자재조합": new_um, + "본뜬": origin, + } if path: made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움 doc["열"] = [*doc.get("열", []), made] @@ -154,6 +161,7 @@ def copy_item( body = store._server(kind, body) with store._LOCK: atomic_write_json(store._path(kind, new_id), body) + store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) return {"key": ss["키"], "열": new_id, "이름": ss["이름"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 4321aee0f..7beaecbb7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -316,18 +316,36 @@ def post_use_combo( return _call(folder, branch.use_combo, column, body.종류, body.키, body.판) +def _copy_practice(column: str, key: str) -> dict[str, Any]: + """실무문서 조합(마스터 UA) → 이 열 일위대가 문서(입력값 빈 값 · 39-1b).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + if not key.startswith("UA"): + raise store.StoreError(400, f"실무문서 조합 키가 아님 「{key}」") + return store.copy_from("labor", column, key, body=labor.from_combo(key)) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, - kind: Literal["structures", "area", "material"], + kind: Literal["structures", "labor", "area", "material"], column: str, source: str = Query(alias="from"), owner: str | None = None, + from_layer: Literal["system", "company", "personal", "practice"] | None = None, + from_owner: str | None = None, session: Session = Depends(verify_session), ): + """항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) · + 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만).""" folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind - return _call(folder, store.copy_from, part, column, source) + if from_layer == "practice": + if part != "labor" or not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만") + return _call(folder, _copy_practice, column, source) + origin = _root(session, from_layer, from_owner) if from_layer else None + return _call(folder, store.copy_from, part, column, source, origin) @router.get("/layers/{layer}/material-combos") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 0ff11389e..e8bedb6c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -186,6 +186,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: _ensure(part, column, source) + _refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 _copy_inputs(source, column) backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() @@ -335,7 +336,8 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool: def _take_origins(doc: Any) -> dict[str, str]: """표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) · - 그 열에 일위대가 · 자재조합 키가 비었으면 원본 열 것을 받음.""" + 그 열에 자재조합 키가 비었으면 원본 열 것을 받음 · 일위대가는 원본 열 문서를 새 번호로 복사 + (`_ensure` · 39-1b — 조합 키는 안 받음).""" cols = ( [c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else [] ) @@ -348,8 +350,7 @@ def _take_origins(doc: Any) -> dict[str, str]: if (root() / "structure" / f"{col['id']}.json").is_file(): continue out[col["id"]] = source - for field in ("일위대가", "자재조합"): - col[field] = col.get(field) or by_id[source].get(field) + col["자재조합"] = col.get("자재조합") or by_id[source].get("자재조합") return out @@ -365,17 +366,32 @@ def create_structure(column: str) -> dict[str, Any]: return read("structure", column) -def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: +def copy_from( + kind: str, + column: str, + source: str, + source_root: Path | None = None, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: """다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은 - 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂.""" + 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 · + `source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌 + (실무문서 조합 → 일위대가 · 39-1b).""" if kind not in _KEY: raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] - doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} + if body is None and source_root is not None: + with at(source_root): + body = read(kind, source)["문서"] + body = read(kind, source)["문서"] if body is None else body + doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}} doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}) with _LOCK: atomic_write_json(_path(kind, column), doc) - return _info(kind, _path(kind, column)) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + return info def _copy_inputs(source: str, column: str) -> None: diff --git a/resources/tester/test_m02_labor_copy.py b/resources/tester/test_m02_labor_copy.py new file mode 100644 index 000000000..56ca1b727 --- /dev/null +++ b/resources/tester/test_m02_labor_copy.py @@ -0,0 +1,134 @@ +"""일위대가 복사(PLAN 39-1b B5 · B6) — 항목 하나 복사(같은 층 · 다른 층 · 실무문서 조합) · +구조물 조합 복사 · 분류 더하기 = 새 일위대가번(조합 키는 안 받음). + +시스템 층 = 견본 표(임시 폴더) · 조합 · 로직은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/copy" + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + for cid in ("rv", "c1"): + store.create_structure(cid) + got = store.read("labor", "rv") + logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}] + store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system, "personal": personal} + + +def _as(world: dict, role: str = "USER") -> TestClient: + world["session"]["role"] = role + return world["client"] + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world): + system = world["system"] + before = _labor(system, "c1") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", "labor", "c1") + "?from=rv") + assert got.status_code == 200 + c1 = _labor(system, "c1") + assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"]) + assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN) + assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈 + + +def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world): + client, personal = _as(world), world["personal"] + url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system" + for _ in range(2): # 저장된 데이터도 다시 바꿈 + assert client.post(url).status_code == 200 + assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN + area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system" + assert client.post(area).status_code == 200 + + +def test_권한_없는_원본_층_403_없는_원본_404(world): + client = _as(world) + other = URL.format("personal", "labor", "a") + "?from=x&from_layer=personal&from_owner=7/99" + assert client.post(other).status_code == 403 + missing = URL.format("personal", "labor", "a") + "?from=없는열&from_layer=system" + assert client.post(missing).status_code == 404 + # 시스템 층 쓰기는 시스템 관리자만 + assert client.post(URL.format("system", "labor", "c1") + "?from=rv").status_code == 403 + + +def test_실무문서_조합에서_일위대가로_시스템_관리자만(world): + url = URL.format("system", "labor", "c1") + "?from=UA000008&from_layer=practice" + assert _as(world).post(url).status_code == 403 + admin = _as(world, "SYSTEM_ADMIN") + assert admin.post(url).status_code == 200 + c1 = _labor(world["system"], "c1") + assert c1["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert len(c1["담은로직"]) == 14 and all(x["입력값"] == {} for x in c1["담은로직"]) + area = URL.format("system", "area", "c1") + "?from=UA000008&from_layer=practice" + assert admin.post(area).status_code == 403 # 실무문서는 일위대가로만 + bad = URL.format("system", "labor", "c1") + "?from=UP1-000001&from_layer=practice" + assert admin.post(bad).status_code == 400 + + +def test_구조물_조합_복사는_새_일위대가번_조합_키는_안_받음(world): + system, personal = world["system"], world["personal"] + with store.at(system): + key = next(r["키"] for r in store.read_combos()["줄"] if r["열"] == "rv") + body = {"from_layer": "system", "key": key} + made = _as(world).post("/api/m02/layers/personal/items/copy", json=body).json() + got = _labor(personal, made["열"]) + src = _labor(system, "rv") + assert got["담은로직"] == src["담은로직"] and got["일위대가번"] != src["일위대가번"] + assert got["일위대가번"].startswith("SU-") + table = json.loads((personal / "table" / f"{store.STRUCTURE_TABLE}.json").read_bytes()) + assert next(c for c in table["열"] if c["id"] == made["열"]).get("일위대가") is None + + +def test_분류_더하기_복사도_새_번호로_일위대가_문서(world): + system = world["system"] + with store.at(system): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + rv = next(c for c in doc["열"] if c["id"] == "rv") + doc["열"].append({**rv, "id": "rv2", "본뜬열": "rv", "일위대가": None}) + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + new, src = _labor(system, "rv2"), _labor(system, "rv") + assert new["담은로직"] == src["담은로직"] and new["일위대가번"] != src["일위대가번"] diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index 1be487e98..c6cbe4fd6 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -169,7 +169,8 @@ def test_회사_항목을_개인으로_복사(world): made = got.json() assert made["열"] == "cw" and made["key"] == "SP42-000001" # 응답 = 개인 층 새 키(바로 고름) mine_rows = user.get("/api/m02/items?layer=personal").json() - assert mine_rows[0]["key"] == made["key"] and mine_rows[0]["names"]["일위대가"] == ua_name + assert mine_rows[0]["key"] == made["key"] + assert mine_rows[0]["names"]["일위대가"] is None # 조합 키는 안 받음 — 일위대가 문서로(39-1b) mine = "/api/m02/layers/personal" copy = user.get(f"{mine}/templates/structure/cw").json()["문서"] assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw" From 36c437d9a1ece40f5940915e5f76225e799c0352 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:51:28 +0900 Subject: [PATCH 27/36] =?UTF-8?q?fix(M02):=20=EB=82=A8=EC=9D=98=20?= =?UTF-8?q?=EC=B8=B5=20=EC=93=B0=EA=B8=B0=20=EA=B8=B0=EB=A1=9D=20=3D=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=ED=95=9C=20=EC=93=B0=EA=B8=B0=EB=A7=8C=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B8=EC=9D=B4=20=EB=8B=B5=ED=95=9C=20=EB=92=A4?= =?UTF-8?q?=20=EB=82=A8=EA=B9=80=20=C2=B7=20=EB=A7=89=ED=9E=98=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EB=B6=80=EB=A6=84=20=EB=BA=8C(39-1d)?= 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_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Router_Items.py | 41 +++++++++++++++++-- .../M02_MasterTemplete_Router_Layers.py | 4 +- resources/tester/test_m02_layer_access.py | 2 +- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 0a40524d3..cef56af78 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -11,11 +11,14 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) from __future__ import annotations import logging +from collections.abc import Callable, Coroutine +from contextvars import ContextVar from pathlib import Path from typing import Any, Literal import anyio.from_thread -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.routing import APIRoute from pydantic import BaseModel from common_util.common_util_audit import record_audit @@ -37,7 +40,32 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import ( ) logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) +#: 이 요청이 쓴 남의 층(`others_write`) — 길이 성공해 답한 뒤에만 시스템 로그(`NotedRoute`) +_WRITES: ContextVar[list[tuple[dict, access.Grant]] | None] = ContextVar("m02_writes", default=None) + + +class NotedRoute(APIRoute): + """남의 층 쓰기 기록 = 성공한 쓰기만(39-1d) — 막히거나 실패한 부름(예외 · 4xx · 5xx)은 뺌.""" + + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def run(request: Request) -> Response: + notes: list[tuple[dict, access.Grant]] = [] + token = _WRITES.set(notes) + try: + response = await handler(request) + finally: + _WRITES.reset(token) + if response.status_code < 400: + for session, got in notes: + await _note(session, got) + return response + + return run + + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"], route_class=NotedRoute) Layer = Literal["system", "company", "personal"] Session = dict[str, Any] @@ -114,12 +142,17 @@ async def _note(session: Session, got: access.Grant) -> None: async def others_write(session: Session, got: access.Grant, folder: Path) -> None: - """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 쓸 때마다 시스템 로그.""" + """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 시스템 로그는 길이 성공한 + 뒤(`NotedRoute` · 그 밖의 길은 바로).""" if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): if not await _member(str(got.owner)): raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님") items_store.ensure(folder) - await _note(session, got) + pending = _WRITES.get() + if pending is None: + await _note(session, got) + else: + pending.append((session, got)) def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index f3097f50b..a2e2aa95d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -34,7 +34,9 @@ from M02_MasterTemplete import M02_Template_Layers as layers logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"]) +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Layers"], route_class=items_router.NotedRoute +) Layer = Literal["system", "company", "personal", "project"] diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py index f89e29ea9..3f0ac4938 100644 --- a/resources/tester/test_m02_layer_access.py +++ b/resources/tester/test_m02_layer_access.py @@ -160,7 +160,7 @@ def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any] add["판"] = "" assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409 assert (storage / "9/templates/table/구조물집계표.json").is_file() - assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"] + assert [n[2] for n in world["notes"]] == ["7/44"] # 성공한 쓰기만(39-1d) · 409 · 403 은 안 남김 def test_남의_층_목록(world: dict[str, Any]): From d21f258da6be613a7ff24ec83df2bd22b0323e9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:52:05 +0900 Subject: [PATCH 28/36] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=95=8C=EC=95=BD=20=3D=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20pills=20=EA=B8=B8=20=E2=80=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=AC=BC=EB=9F=89=20=E3=80=8C=EC=9D=B4=EB=A6=84|?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=E3=80=8D=20+=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=E3=80=8C=EC=9E=90=EC=9E=AC|MT=ED=82=A4=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=95=8C=EC=95=BD=20=EB=B9=A0=EC=A7=90=20?= =?UTF-8?q?(PLAN=2039-4=20D7)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_Api_Fetch.ts | 14 ++++++++ .../M02_MasterTemplete_Structure.ts | 35 +++++++++---------- resources/tester/test_m02_card_sheet_edit.py | 12 +++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6ac97e28e..801fa9c18 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -301,6 +301,20 @@ export const copyStructure = async (column: string, from: string): Promise await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); }; +/** 면적산출 알약 하나 — 로직 물량 「이름|단위」(`로직` · `배`) · 자재 「자재|MT키」(`자재`) */ +export interface LaborPill { + 키: string; + 이름: string; + 단위: string; + 로직?: string[]; + 배?: number; + 자재?: { 코드: string; 이름: string; 규격: string; 단위: string }; +} + +/** 그 열 일위대가의 면적산출 알약(저장본 · PLAN 39-1a pills 길) — 지금 층 */ +export const fetchLaborPills = (column: string): Promise<{ 열: string; 알약: LaborPill[] }> => + call(owned(activeLayer, `/layers/${activeLayer}/labor/${enc(column)}/pills`)); + /** 다른 열(`from`) 면적산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 면적번 · 열은 그대로 */ export const copyArea = async (column: string, from: string): Promise => { await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 30a1b1bea..6576d077c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -24,14 +24,14 @@ import { import { openModal } from "@ui/ui_template_modal"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; -import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; +import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; import { copyArea, copyMaterial, copyStructure, + fetchLaborPills, fetchStructureNumbers, getActiveLayer, - readTemplate, saveTemplate, type StructureNumbers, type TreeEdit, @@ -67,9 +67,8 @@ const TEXT = { pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", copyFailed: "복사 실패 — {value}", - noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름", - noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음", - inputsFailed: "일위대가 입력값을 못 받음 — {value}", + noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", + pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출 · 재료산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; @@ -275,7 +274,8 @@ export const columnHead = (col: StructureColumn): string => export const structureLabel = (number: string, col: StructureColumn): string => [number, columnHead(col)].filter(Boolean).join(" "); -/** 면적산출 · 재료산출 편집 화면 위 알약 막대 — 면적 = 그 열 일위대가 입력값(물량) · 재료 = 자재DB 찾기. +/** 면적산출 · 재료산출 편집 화면 아래 알약 막대 — 면적 = 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」 + * (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽) · 재료 = 자재DB 찾기. * 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */ export async function pillsFor( kind: SheetKind, @@ -285,20 +285,19 @@ export async function pillsFor( const own = await inputPills(column, "재료"); return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } }; } - const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력 - const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice }); - const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch( - () => null, - ); - const cols = (table?.문서 as { 열?: { id: string; 일위대가?: string | null }[] } | null)?.열; - const combo = cols?.find((c) => c.id === column)?.일위대가; - if (!combo) return none(TEXT.noPrice); + const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice }); try { - const got = await fetchComboInputs(combo); - const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own]; - return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo)); + const { 알약 } = await fetchLaborPills(column); + const 항목 = 알약.map((p) => ({ + 키: p.키, + 이름: p.이름, + 단위: p.단위, + ...(p.자재 ? { 자재: p.자재 } : {}), + 고정: true, + })); + return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills); } catch (error) { - return none(fill(TEXT.inputsFailed, why(error))); + return none(fill(TEXT.pillsFailed, why(error))); } } diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index 6dbe46cf5..8c450a75d 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -89,3 +89,15 @@ def test_면적산출_카드_알약_줄_켬끔_기본_숨김(): assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state # 상세(전체 편집기)는 켬끔 없음 — 늘 보임 assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts") + + +def test_면적산출_알약은_일위대가_pills_길(): + """39-4 D7 — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 · 선택 입력 알약 빠짐.""" + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + pills = struct.split("export async function pillsFor")[1].split("async function cardLinks")[0] + area = pills.split('if (kind === "material")')[1].split("\n }\n", 1)[1] + assert "await fetchLaborPills(column)" in area + assert "고정: true" in area and "...(p.자재 ? { 자재: p.자재 } : {})" in area + assert "inputPills" not in area and "fetchComboInputs" not in struct + api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + assert "/layers/${activeLayer}/labor/${enc(column)}/pills" in api From 6cf72bbf1170f5f74de5868f0cc61493c74b6a50 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:52:56 +0900 Subject: [PATCH 29/36] =?UTF-8?q?feat(M02):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B9=88=20=EC=B9=B8=20=EB=91=90=20=EB=8B=A8=EC=B6=94=20=C2=B7?= =?UTF-8?q?=20=E3=80=8C=EB=B3=B5=EC=82=AC=ED=95=B4=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=E3=80=8D=20=E2=80=94=20=EB=B9=88=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20[=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0]?= =?UTF-8?q?=20[=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1]=20=C2=B7=20?= =?UTF-8?q?=EC=B0=AC=20=EC=B9=B4=EB=93=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A7=91=EA=B3=84=ED=91=9C=20=E3=80=8C?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=9B=84=20=EC=84=A0=ED=83=9D=E3=80=8D=20?= =?UTF-8?q?=EB=8F=84=20=ED=83=AD=20+=20=EB=AA=A9=EB=A1=9D=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 가져오기 = ItemPick 창(탭 · 받는 열 자신 뺌) → 덮어쓰기 확인 → 내용만 복사(번호 · 열 그대로) · 같은 곳은 지금 길 · 다른 층 · 사람은 서버 층 넘김 길(39-1b) 뒤 이음(지금은 막음 알림) · 일위대가 카드는 보기 부품(39-3) 뒤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 69 ++++++++++++++++++- .../M02_MasterTemplete_ItemPick.ts | 21 +++++- .../M02_MasterTemplete_Structure.css | 16 +++++ .../M02_MasterTemplete_Structure.ts | 59 ++++++++-------- resources/tester/test_m02_card_shell.py | 2 +- resources/tester/test_m02_structure_panel.py | 2 +- 6 files changed, 134 insertions(+), 35 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 3b3a24b3f..d00f83160 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -65,6 +65,11 @@ const TEXT = { sheetHelp: "단축키 — 칸을 고르고 바로 침", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", + take: "복사해 가져오기", + takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", + blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -81,6 +86,11 @@ const SHEET_KEYS: [string, string][] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */ +const emptyBook = (book: Workbook): boolean => + (book.시트 ?? []).every( + (s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식), + ); const fill = (text: string, value: string): string => text.replace("{value}", value); /** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); @@ -102,6 +112,8 @@ export interface ItemCardsOptions { onDirty?: (dirty: boolean) => void; /** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; + /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ + onImport?: (kind: EditKind) => void; } /** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */ @@ -154,6 +166,8 @@ interface Card { saver: SaveControl; help: HTMLElement; edit: HTMLButtonElement; + /** 「복사해 가져오기」 — 찬 카드만 */ + take: HTMLButtonElement; no: HTMLElement; name: HTMLElement; } @@ -165,6 +179,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { let docVersion: string | null = null; let preview: DrawingCardHandle | null = null; let seq = 0; + /** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */ + const started = new Set(); const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); @@ -192,8 +208,44 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { size: "sm", }); edit.addEventListener("click", () => opts.onEdit(kind)); - shell.tools.append(help, saver.button, edit); - return { kind, shell, saver, help, edit, no, name }; + const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); + take.title = fill(TEXT.takeTip, PART_WORD[kind]); + take.addEventListener("click", () => opts.onImport?.(kind)); + shell.tools.append(help, saver.button, take, edit); + return { kind, shell, saver, help, edit, take, no, name }; + }; + + /** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */ + const blank = (c: Card, empty: boolean): boolean => { + const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind); + c.take.hidden = show || !opts.editable() || !opts.onImport; + return show; + }; + /** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */ + const startBox = (c: Card, paint: () => void): HTMLElement => { + const fresh = createButton({ + label: TEXT.makeNew, + variant: "ghost", + size: "sm", + onClick: () => { + started.add(c.kind); + paint(); + }, + }); + const copy = createButton({ + label: TEXT.makeCopy, + variant: "ghost", + size: "sm", + onClick: () => opts.onImport?.(c.kind), + }); + copy.hidden = !opts.onImport; + return el("div", { + className: "m02-struct__start", + children: [ + el("p", { className: "m02-struct__empty", text: TEXT.blank }), + el("div", { className: "m02-struct__start-row", children: [fresh, copy] }), + ], + }); }; const previewHost = el("div", { className: "m02-struct__preview" }); @@ -328,6 +380,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { * 그 사이 열이 바뀌면 버림 */ const paintSheet = async (c: SheetCard, mine: number): Promise => { if (!c.doc) return; + if (blank(c, emptyBook(c.doc))) + return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq))); + c.host.replaceChildren(); try { const { createSpreadsheet } = await loadSpreadsheet(); if (mine !== seq || !c.doc) return; @@ -348,14 +403,21 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]); void paintSheet(c, mine); } + paintDrawing(); + }; + + /** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */ + const paintDrawing = (): void => { if (!doc) return; previewHost.replaceChildren(); + if (blank(info, !doc.도면?.entities?.length)) + return void previewHost.append(startBox(info, paintDrawing)); // 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`) preview = mountDrawingCard(previewHost, { doc, version: docVersion, source: opts.source(), - column: at, + column: column ?? "", editable: opts.editable(), saver: info.saver, }); @@ -384,6 +446,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + if (col.id !== column) started.clear(); column = col.id; label = col.label; for (const c of cards) c.name.textContent = label; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 3ee87e241..4fd1b1215 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -9,7 +9,7 @@ import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchSessionUser, type SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { buildItemList, @@ -20,7 +20,13 @@ import { type ListKind, } from "./M02_MasterTemplete_ItemList"; import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; -import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { + entryFromHash, + ownOwner, + readableSources, + viewCompany, + type Source, +} from "./M02_MasterTemplete_Layer"; import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; import "./M02_MasterTemplete_TreePanel.css"; import "./M02_MasterTemplete_ItemPick.css"; @@ -236,3 +242,14 @@ export function pickFrom( }); }); } + +/** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */ +export async function pickHere( + opt: Omit & { + title: string; + ask?: (picked: Picked) => string; + }, +): Promise { + const user = await fetchSessionUser().catch(() => null); + return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 57eaacbce..a28d80de4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -196,6 +196,22 @@ color: var(--color-text-secondary); } +/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */ +.m02-struct__start { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-8); + padding-bottom: var(--spacing-16); +} + +.m02-struct__start-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding: 0 var(--spacing-16); +} + /* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */ .m02-pick__list { display: flex; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 30a1b1bea..c473b08f0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -31,6 +31,7 @@ import { copyStructure, fetchStructureNumbers, getActiveLayer, + getActiveOwner, readTemplate, saveTemplate, type StructureNumbers, @@ -47,6 +48,7 @@ import { } from "./M02_MasterTemplete_ItemCards"; import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials"; import { layerSource } from "./M02_MasterTemplete_ItemSource"; +import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick"; import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; @@ -66,6 +68,7 @@ const TEXT = { overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", + acrossLater: "다른 곳(층 · 사람)에서 가져오기는 서버 길이 선 뒤 — 지금은 같은 곳만", copyFailed: "복사 실패 — {value}", noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름", noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음", @@ -414,6 +417,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], extraCards: [inputs.root], + // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 + onImport: (kind) => void pickCopy(kind, shown ?? undefined), // 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 sheetEdit: { can: () => opts.isAdmin, @@ -457,37 +462,31 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane return got ? got.key : null; }; - /** 다른 열의 도면 · 면적산출 · 재료산출을 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */ - const pickCopy = async ( - kind: EditKind, - colId: string, - columns: StructureColumn[], - ): Promise => { - const field = PART_FIELD[kind]; + /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · + * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ + const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise => { const word = PART_WORD[kind]; - const copy = COPY[kind]; - const to = columns.find((c) => c.id === colId); - const toNo = numbers[colId]?.[field]; + const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); - const items = columns - .filter((c) => c.id !== colId && numbers[c.id]?.[field]) - .map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) })); - const from = await choose( - `${fill(TEXT.pickCopy, word)} — ${toNo} ${columnHead(to)}`, - items, - false, - ); - const src = columns.find((c) => c.id === from); - if (!from || !src) return; - const ask = TEXT.overwrite - .replace("{from}", `${numbers[from][field]} ${columnHead(src)}`) - .replace("{what}", word) - .replace("{to}", `${toNo} ${columnHead(to)}`); - if (!(await showConfirmDialog(ask))) return; + const target = `${toNo} ${columnHead(to)}`; + const here = { layer: getActiveLayer(), owner: getActiveOwner() }; + const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; + const picked = await pickHere({ + title: `${fill(TEXT.pickCopy, word)} — ${target}`, + kind: kind === "material" ? "item" : kind, + skip: (from, row) => same(from) && row.열 === to.id, + ask: (p) => + TEXT.overwrite + .replace("{from}", `${p.from.label} ${pickedName(p)}`) + .replace("{what}", word) + .replace("{to}", target), + }); + if (!picked || !("row" in picked)) return; try { - await copy(colId, from); + if (!same(picked.from)) throw new Error(TEXT.acrossLater); // 층 넘는 복사 길(39-1b) 뒤에 이음 + await COPY[kind](to.id, picked.row.열); showToast(TEXT.copied, "success"); - if (column === colId) void load(shown); + if (column === to.id) void load(shown); } catch (error) { showToast(fill(TEXT.copyFailed, why(error)), "error"); } @@ -500,7 +499,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane ): Promise => { if (!opts.isAdmin || !colId) return null; if (row === "s:price") return pickPrice(colId, columns); - if (ROW_KIND[row]) await pickCopy(ROW_KIND[row], colId, columns); + if (ROW_KIND[row]) + await pickCopy( + ROW_KIND[row], + columns.find((c) => c.id === colId), + ); return null; }; diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index f0070d654..4972c8711 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, edit);" in cards + assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5) assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index b10a492d8..51b58e851 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -224,7 +224,7 @@ def test_표_도구_막대에_줄_더하기_줄_지우기_손_입력_열_없음( def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): main = _text(MAIN) - assert "children: [title, back, slot, reload, save, " in main # 제목 바로 오른쪽 + assert "children: [back, title, slot, reload, save, " in main # M01 꼴 — 제목 앞(39-5) assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 면적산출 · 재료산출만 body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body From dcce47b6d95278e9f90840cbd970b6a28d0b4fe4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:54:24 +0900 Subject: [PATCH 30/36] =?UTF-8?q?test(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=EC=A0=9C=EB=AA=A9=20=EC=A4=84=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=83=88=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=20=EA=BC=B4(=E2=86=90=20=EC=A0=9C=EB=AA=A9=20=EC=95=9E)=20?= =?UTF-8?q?=C2=B7=20=E3=80=8C=EC=83=88=20=ED=95=AD=EB=AA=A9=E3=80=8D=20=3D?= =?UTF-8?q?=20=EB=91=90=20=EB=8B=A8=EC=B6=94=20=ED=99=94=EB=A9=B4=20=C2=B7?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=ED=8F=B4=EB=8D=94=20folder()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 134a727c9 · 364d22ff6 뒤 기대값 맞춤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- resources/tester/test_m02_layer_screen.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 721a75faa..ce9c04253 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -301,17 +301,15 @@ def test_구조물_조합_제목_줄_새_항목(): main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main assert ( - "children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]" + "children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]" in main ) assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert ( - "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {})" - in page - ) + assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {})" in page panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") - assert 'void addItem(at?.kind === "folder" ? at.path : []);' in panel + assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder()) + assert "const newItem = (): void => void addItem(folder());" in panel assert ".m02-main__new {\n margin-left: auto;" in _text( M02 / "M02_MasterTemplete_UI_Style.css" ) From e1ad3d3625b415d17ecccf78bffd593ee12927fb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:55:58 +0900 Subject: [PATCH 31/36] =?UTF-8?q?feat(M02):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EB=AC=B8=EC=84=9C=20=E2=80=94=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20=C2=B7=20=EC=9E=85=EB=A0=A5=EA=B0=92=20?= =?UTF-8?q?=EC=B2=AB=20=EA=B0=92=20=C2=B7=20=EC=9E=90=EC=9E=AC=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EC=88=98=EB=9F=89=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20(39-1c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Item_Source: 층 labor/<열>.json 이 있으면 일위대가.json = {줄: 문서, 로직, 빠진로직, 알약} · 없으면 옛 UA 조합 줄 · 단가 묶음에 자재 MT - Item_Snapshot: 입력값.json 첫 값 = 문서 입력값 {로직: {입력: {값, 확인: false}}} - Item_Prices: collect 가 자재 MT 도 · calc 가 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합(M02_Labor_Doc.settle 한 벌 · 면적 알약 칸 값) - Item_Copy · Project_Items_Router: 일위대가 종류 읽기만(쓰면 400) · 재적용은 새 첫 값 위에 프로젝트 값 · calc 에 면적 - 시험 test_m02_item_labor.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- M02_MasterTemplete/M02_Item_Copy.py | 11 +- M02_MasterTemplete/M02_Item_Prices.py | 54 ++++- M02_MasterTemplete/M02_Item_Snapshot.py | 21 +- M02_MasterTemplete/M02_Item_Source.py | 49 +++-- .../M02_Project_Items_Router.py | 6 +- resources/tester/test_m02_item_labor.py | 207 ++++++++++++++++++ 6 files changed, 321 insertions(+), 27 deletions(-) create mode 100644 resources/tester/test_m02_item_labor.py diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index ba8fec059..9924f16c1 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -55,6 +55,7 @@ KINDS = { "입력연결": LINK_FILE, "입력값": VALUES_FILE, "고른자재": PICKS_FILE, + "일위대가": LABOR_FILE, # 읽기만 — 일위대가는 원본 층에서 고침(39-1c) } #: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출) SKELETON = {"도면": "structure", "면적": "area", "재료": "material"} @@ -171,6 +172,8 @@ def _check_links(content: Any) -> None: def check(kind: str, content: Any) -> None: + if kind == "일위대가": + raise ValueError("일위대가는 원본(마스터 · 회사 · 개인) 층에서 고침 — 프로젝트는 넣을 값만") if kind == "입력값": _check_values(content) elif kind == "고른자재": @@ -253,7 +256,10 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]: before = {name: read_json(target / file) for name, file in KINDS.items()} files = build_snapshot(manifest["원본"]) labor = files.get(LABOR_FILE) or {} - files[VALUES_FILE] = _kept_values(values, labor) + seeded = files[VALUES_FILE] # 새 원본의 첫 값 — 프로젝트에서 넣은 값이 같은 키면 이김 + kept_values = _kept_values(values, labor) + for logic, inputs in kept_values.items(): + seeded[logic] = {**seeded.get(logic, {}), **inputs} files[PICKS_FILE] = _kept_picks(picks, labor) for old in target.glob("*.json"): # 원본에서 빠진 파일(도면 없앰 등)은 지움 if old.name != MANIFEST_NAME and old.name not in files: @@ -268,7 +274,7 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]: source = version_of(source_files(files)) stamp = now_text() # 원본을 다시 가져왔으니 시트 고침은 없음 — 살린 입력값 · 고른 자재는 `값있음` 으로 따로 알림 - kept = bool(files[VALUES_FILE] or files[PICKS_FILE]) + kept = bool(kept_values or files[PICKS_FILE]) fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp, "고침": False} fresh["값있음"] = kept fresh.pop("고친날", None) @@ -343,4 +349,5 @@ def calc(project_root: str | Path, folder: str) -> dict[str, Any]: read_json(target / PRICE_FILE) or {}, read_json(target / VALUES_FILE) or {}, read_json(target / PICKS_FILE) or {}, + read_json(target / KINDS["면적"]), ) diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index c7ab8fee0..a5ae96b66 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -8,9 +8,12 @@ 원문번호 틀의 `{입력}` 은 그 입력의 고르기 값으로 모두 끼워 모음(고르기가 없으면 그 테이블 전부 · 자재품목만은 너무 커서 뺌). 줄은 마스터 파일 차례 그대로 담음 — 고르기의 「첫 줄」 이 같게. +일위대가 자재(MT · 39-1c)도 그 줄(+ 준용 · 연결)을 담음. + `단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가 그대로 읽는 꼴. -계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` 만 씀 — 마스터 현재값을 읽지 않음. +계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터 +현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`). """ from __future__ import annotations @@ -200,12 +203,14 @@ class _Collector: self.pick(row, item["요소"], item.get("단위")) -def collect(logic_keys: list[str]) -> dict[str, Any]: - """담은 로직의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" +def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]: + """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" files, whole = master() collector = _Collector(whole) for key in logic_keys: collector.logic(key) + for code in materials or []: + collector.add(code) out: dict[str, Any] = {} for name, data in files.items(): slot = m01.items_key(data) @@ -256,9 +261,17 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) def calc( - labor: dict[str, Any], prices: dict[str, Any], values: dict[str, Any], picks: dict[str, Any] + labor: dict[str, Any], + prices: dict[str, Any], + values: dict[str, Any], + picks: dict[str, Any], + area: Any = None, ) -> dict[str, Any]: - """복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합.""" + """복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합 + (수량 1 기준). + + 일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 — + 수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌).""" snapshot = load_prices(prices) gaps = missing_inputs(snapshot, labor, values) blocked = {gap["로직"] for gap in gaps} @@ -293,4 +306,33 @@ def calc( "계": got["계"], } ) - return to_json({"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}) + out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck} + return to_json({**out, **_reflected(snapshot, labor, lines, area)}) + + +def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict: + """줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것.""" + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + doc = labor.get("줄") or {} + pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []} + base = [] + for line in lines: + pill = pill_of.get(line["로직"]) or {} + money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None + head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1} + base.append({**head, "분모": pill.get("단위"), "기준": money}) + mats = [] + for one in doc.get("자재") or []: + code = str(one.get("코드") or "") + try: + price, _ = mm.element(snapshot, code, {}, one.get("단위") or None) + except mf.FormulaError: + price = None + mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price}) + settled = labor_doc.settle( + to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area) + ) + for line, done in zip(lines, settled["줄"]): + line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")}) + return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]} diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 3cb25ea8d..6d52b6989 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -6,7 +6,8 @@ [저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤: - 항목을 지정한 구조물마다 원본 층에서 내용을 복사(`M02_Item_Source.build_source`) + 프로젝트 값 - `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · `manifest.json` + `입력값.json`(첫 값 = 일위대가 문서 입력값 · 확인 거짓 · 자동값은 안 적음) · `고른자재.json` · + `manifest.json` - 이미 있는 복사본은 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌거나 지워져도 그대로 ([재적용]은 사용자가) - 지정을 다른 항목으로 바꾸면 새로 가져옴 · 구조물을 지우거나 지정을 풀면 복사본을 지움 — @@ -30,6 +31,7 @@ from common_util.common_util_json import atomic_write_json from common_util.common_util_summary_item import snapshot_folder_name from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Item_Source import ( + LABOR_FILE, LINK_FILE, PRICE_FILE, build_source, @@ -85,9 +87,22 @@ def origin_of(item: dict[str, Any]) -> dict[str, Any]: return {**item, "경로": path} +def seed_values(labor: Any) -> dict[str, Any]: + """일위대가 문서 입력값 → 프로젝트 입력값 첫 값 `{로직: {입력: {값, 확인: false}}}`(39-1c).""" + doc = labor.get("줄") if isinstance(labor, dict) else None + out: dict[str, Any] = {} + for line in (doc or {}).get("담은로직") or []: + given = line.get("입력값") if isinstance(line, dict) else None + if isinstance(given, dict) and given and line.get("로직"): + out[str(line["로직"])] = {k: {"값": v, "확인": False} for k, v in given.items()} + return out + + def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: - """원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" - return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}} + """원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면 + LookupError.""" + files = build_source(item) + return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}} def source_files(files: dict[str, Any]) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index fbda9ce76..961100a1f 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -2,9 +2,11 @@ 모으는 것 = `{파일 이름: 내용}`: `집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM · - `자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) · + `자재조합.json` UM 줄 · + `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄) + + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) · `입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) · - `단가.json` 계산에 닿는 마스터 줄 전부 + `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) `원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. 프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. 원본 층 자리 = `M02_Template_Layers` 경로 · 일위대가 · 단가는 M01 시스템 것. @@ -23,6 +25,7 @@ TABLE_NAME = "구조물집계표" UM_FILE = "자재조합.json" #: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출) PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json")) +LABOR_KIND = "labor" # 열 id → 일위대가 문서(39-1c) — 스냅샷은 `LABOR_FILE` 에 감싸서 LINK_FILE = "입력연결.json" PRICE_FILE = "단가.json" LABOR_FILE = "일위대가.json" @@ -80,15 +83,27 @@ def version_of(content: Any) -> str: def labor_rows(ua_key: str) -> dict[str, Any] | None: - """조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 - `빠진로직`).""" - from M01_MasterData import M01_MasterData_Store as m01 + """옛 조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄 — labor 문서가 없는 열 + (옮기기 전).""" from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos ua = combos.find(ua_key) - if ua is None: - return None - wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")] + return None if ua is None else logic_rows(ua) + + +def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]: + """일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 — + 복사본 계산이 마스터를 안 읽게).""" + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + return {**logic_rows(doc), "알약": labor_doc.pills(doc)} + + +def logic_rows(row: dict[str, Any]) -> dict[str, Any]: + """`{줄: row, 로직: 담은 로직 줄(M01 시스템), 빠진로직: 없는 로직 키}`.""" + from M01_MasterData import M01_MasterData_Store as m01 + + wanted = [str(entry.get("로직")) for entry in row.get("담은로직") or [] if entry.get("로직")] found: dict[str, dict[str, Any]] = {} raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음 for key in wanted: @@ -98,11 +113,11 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None: continue if file not in raw: raw[file] = read_json(Path(m01.FOLDER) / file) - row = row_of(raw[file], key) - if row is not None: - found[key] = row + got = row_of(raw[file], key) + if got is not None: + found[key] = got return { - "줄": ua, + "줄": row, "로직": [found[key] for key in wanted if key in found], "빠진로직": [key for key in wanted if key not in found], } @@ -142,10 +157,16 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: um = row_of(read_json(root / UM_FILE), str(row["자재조합"])) if um is not None: files[UM_FILE] = um - labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None + doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None + if isinstance(doc, dict): + labor = labor_doc_rows(doc) + else: + labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: files[LABOR_FILE] = labor files[LINK_FILE] = input_links(root, column_id) logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] - files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys)) if logic_keys else {} + materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []] + wanted = logic_keys + [code for code in materials if code] + files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} return files diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 6214c6968..1cac7ac39 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -9,7 +9,9 @@ POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림) POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감) POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력) -종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용). + 일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합 +종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) · +`refresh` = 옛 이름(재적용). ⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404). """ @@ -31,7 +33,7 @@ from M02_MasterTemplete.M02_Item_Source import item_name router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"]) -Kind = Literal["도면", "면적", "재료", "자재조합", "입력연결", "입력값", "고른자재"] +Kind = Literal["도면", "면적", "재료", "자재조합", "입력연결", "입력값", "고른자재", "일위대가"] class WriteBody(BaseModel): diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py new file mode 100644 index 000000000..80321bdcc --- /dev/null +++ b/resources/tester/test_m02_item_labor.py @@ -0,0 +1,207 @@ +"""프로젝트 복사본 · 일위대가 문서(PLAN 39-1c · 임시 저장소 · 임시 마스터 사본). + +- 층 `labor/<열>.json` 이 있으면 스냅샷 `일위대가.json` = {줄: 문서, 로직, 빠진로직, 알약} · + 단가 묶음에 자재 MT +- `입력값.json` 첫 값 = 문서 입력값(확인 거짓) · 재적용은 프로젝트에서 넣은 값이 이김 +- 계산 = 수량(손 · 면적 알약 칸) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 기준 단가 × 수량 · + 반영합 = 손셈 · + 기준 · 반영이 원본 층 문서 계산(`M02_Labor_Doc.calc`)과 같음 +- 일위대가 쓰기는 400(읽기만) +""" + +from __future__ import annotations + +import json +import shutil +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest + +from config import config_system +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE + +MASTER = config_system.PROJECT_ROOT / "resources/master_data" +PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} +COLUMN = "pp_len" +FOLDER = "pipe@40.0" +LOGICS = ("GF000176", "GC000088", "GF000113") +MATERIAL = "MT000008" +HAND = 3 # GC000088 손 수량 +AREA_QTY = 7.5 # 자재 · GF000113 은 면적 알약 칸 값 +MAT_QTY = 4 + + +@pytest.fixture(scope="module") +def master_copy(tmp_path_factory) -> Path: + target = tmp_path_factory.mktemp("master_data") + for path in MASTER.glob("*.json"): + shutil.copy2(path, target / path.name) + return target + + +def _read(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write(path: Path, content: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(content, ensure_ascii=False), encoding="utf-8") + + +def _first_input(code: str) -> tuple[str, Any]: + whole = m01.loaded()[1] + name, value = next(iter(m01.mau.values(whole, whole.logic(code)).items())) + return name, int(value) if isinstance(value, Decimal) and value % 1 == 0 else value + + +@pytest.fixture +def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]: + system = tmp_path / "master" + system.mkdir() + for name in (layers.COMBO_FILE, "자재조합.json"): + shutil.copy2(SAMPLE / name, system / name) + for kind in ("table", "structure", "area", "material"): + shutil.copytree(SAMPLE / kind, system / kind) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(m01, "FOLDER", master_copy) + storage = tmp_path / "storage" + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + root = storage / "3" / "12" / "proj" + (root / "B05_Profile/route").mkdir(parents=True) + (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) + + name, value = _first_input("GC000088") + doc = labor_doc.clean( + { + "열": COLUMN, + "단위": "m", + "담은로직": [ + {"로직": LOGICS[0]}, + {"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}}, + {"로직": LOGICS[2]}, + ], + "자재": [{"코드": MATERIAL}], + } + ) + _write(system / "labor" / f"{COLUMN}.json", doc) + pills = {p["키"]: p for p in labor_doc.pills(doc)} + area_pill = next(k for k, p in pills.items() if LOGICS[2] in p.get("로직", [])) + area = _read(system / "area" / f"{COLUMN}.json") + sheet = area["시트"][0] + sheet["칸"] = {"A1": {"값": AREA_QTY}, "A2": {"값": MAT_QTY}} + sheet["연결"] = [ + {"키": area_pill, "칸": "A1"}, + {"키": labor_doc.material_key(MATERIAL), "칸": "A2"}, + ] + _write(system / "area" / f"{COLUMN}.json", area) + return { + "root": root, + "doc": doc, + "input": (name, value), + "pills": pills, + "area_pill": area_pill, + } + + +def _save(root: Path) -> dict[str, list[str]]: + _write(root / "B05_Profile/route/structures.json", {"revision": 1, "structures": []}) + pipe = {"chainage_m": 40.0, "source": "user", "summary_item": PIPE} + _write(root / "B04_PreProcess/drainage/edits/pipe_points.json", {"points": [pipe]}) + return snap.sync_snapshots(root) + + +def _fill_auto(root: Path) -> None: + """빠진 입력을 자동값으로(확인 참) — 문서 입력값은 그대로 둠.""" + current = copies.read_file(root, FOLDER, "입력값") + values = current["내용"] + whole = m01.loaded()[1] + for code in LOGICS: + for key, auto in m01.mau.values(whole, whole.logic(code)).items(): + mine = values.setdefault(code, {}) + if key not in mine and auto is not None: + mine[key] = {"값": labor_doc._json(auto), "확인": True} + copies.write_file(root, FOLDER, "입력값", current["판"], values) + + +def test_스냅샷_일위대가는_문서_알약_자재_단가(world): + root = world["root"] + assert _save(root)["만듦"] == [FOLDER] + folder = root / "templates/structures" / FOLDER + labor = _read(folder / "일위대가.json") + assert labor["줄"] == world["doc"] + assert [row["키"] for row in labor["로직"]] == list(LOGICS) and labor["빠진로직"] == [] + assert {p["키"] for p in labor["알약"]} == set(world["pills"]) + keys = { + row["키"] for data in _read(folder / "단가.json").values() for row in data.get("줄", []) + } + assert MATERIAL in keys and set(LOGICS) <= keys + + +def test_입력값_첫_값은_문서_입력값(world): + root = world["root"] + _save(root) + name, value = world["input"] + values = _read(root / "templates/structures" / FOLDER / "입력값.json") + assert values == {LOGICS[1]: {name: {"값": value, "확인": False}}} + + +def test_재적용은_프로젝트_값이_이김(world): + root = world["root"] + _save(root) + name, _ = world["input"] + current = copies.read_file(root, FOLDER, "입력값") + mine = {LOGICS[1]: {name: {"값": 99, "확인": True}}} + copies.write_file(root, FOLDER, "입력값", current["판"], mine) + got = copies.reapply(root, FOLDER) + assert got["값있음"] is True + assert _read(root / "templates/structures" / FOLDER / "입력값.json") == mine + + +def test_계산_반영_합은_손셈_원본과_같음(world): + root = world["root"] + _save(root) + _fill_auto(root) + got = copies.calc(root, FOLDER) + assert got["멈춤"] == [], got["줄"] + lines = {line["로직"]: line for line in got["줄"]} + hand, area = lines[LOGICS[1]], lines[LOGICS[2]] + assert (hand["수량"], hand["수량출처"]) == (HAND, "손") + assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸") + assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None + for line in (hand, area): + expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"])) + assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect) + (mat,) = got["자재"] + assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] + assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY) + total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"] + assert got["반영합"]["계"] == pytest.approx(total) + # 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것 + values = _read(root / "templates/structures" / FOLDER / "입력값.json") + doc = { + **world["doc"], + "담은로직": [ + {**one, "입력값": {k: v["값"] for k, v in values.get(one["로직"], {}).items()}} + for one in world["doc"]["담은로직"] + ], + } + given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY} + origin = labor_doc.calc(doc, given) + assert got["반영합"] == pytest.approx(origin["반영합"]) + assert got["기준합"] == pytest.approx(origin["기준합"]) + + +def test_일위대가_쓰기는_막힘(world): + root = world["root"] + _save(root) + current = copies.read_file(root, FOLDER, "일위대가") + assert current["내용"]["줄"]["열"] == COLUMN + with pytest.raises(ValueError, match="원본"): + copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"]) From ecc4a651a4a606e058095f2eda6ddd56c8640aaa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:57:07 +0900 Subject: [PATCH 32/36] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0=20=EA=B2=80=EC=82=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=B8=B5=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EA=B0=80=20=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20(39-1b=20B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 저장 전 검사(check_saved)에 labor_errors — 시스템 층 · storage 회사 · 개인 층 labor/*.json 이 담은 로직이 없어지면 막음(프로젝트 층 빼고 · 실무문서는 조합 검사 그대로) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M01_MasterData/M01_MasterData_Store_Cache.py | 36 +++++++++++++++++++- resources/tester/test_m02_labor_doc.py | 27 +++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index c704de769..7de46444a 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -16,12 +16,17 @@ import threading from decimal import Decimal from pathlib import Path +from config import config_system + SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" sys.path.insert(0, str(SCRIPTS)) import check_master as cm # noqa: E402 import master_material as mm # noqa: E402 (자재품목 테이블ID — 재료 고르기는 그 묶음을 훑음) +# 구조물 일위대가 문서(M02 `labor/<열>.json` · 39-1b) 시스템 층 — storage 층은 따로 · 시험이 바꿈 +LABOR_ROOTS: tuple[Path, ...] = (config_system.PROJECT_ROOT / "resources" / "master_template",) + _FRESH = threading.RLock() _READ: dict[str, tuple[tuple, dict]] = {} # 파일 이름 → (자국, 읽은 내용) _INDEX: dict = {} # 폴더 · 자국 · files · whole @@ -217,7 +222,36 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) out += cm.check_links(whole) - return out + cm.mcb.check_all(files, whole) + return out + cm.mcb.check_all(files, whole) + labor_errors(whole) + + +def _labor_files() -> list[Path]: + base = Path(config_system.STORAGE_BASE_DIR) + roots = [*LABOR_ROOTS] + if base.is_dir(): + roots += [*base.glob("*/templates"), *base.glob("*/*/templates")] # 프로젝트 빼고 + return [p for root in roots for p in sorted((root / "labor").glob("[!._]*.json"))] + + +def labor_errors(whole) -> list[str]: + """모든 층 구조물 일위대가가 담은 로직이 마스터에 없으면 한 줄씩 — 쓰는 로직 지우기를 막음 + (실무문서 = 마스터 조합은 `check_all` 몫).""" + out = [] + for path in _labor_files(): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + for one in doc.get("담은로직") or []: + code = str(one.get("로직") or "") if isinstance(one, dict) else "" + try: + whole.logic(code) + except cm.mf.FormulaError: + where = f"{path.parent.parent.name}/{path.stem}" + out.append( + f"구조물 일위대가 {where} 이 담은 로직 「{code}」 이 없음 — 쓰는 로직은 못 지움" + ) + return out def warm(folder: Path) -> threading.Thread: diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index d4ae1e3f8..9290a00b8 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -211,3 +211,30 @@ def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client): assert client.post(url.format("system", "없는열"), json={}).status_code == 404 other = client.post(url.format("personal", "rv") + "?owner=7/99", json={}) assert other.status_code == 403 + + +def test_로직_지우기_검사는_모든_층_일위대가를_봄(tmp_path, monkeypatch): + from config import config_system + from M01_MasterData import M01_MasterData_Store_Cache as cache + + system, storage = tmp_path / "system", tmp_path / "storage" + for folder, code in ( + (system / "labor", STONE), + (storage / "7" / "templates" / "labor", TEN), # 회사 층 + (storage / "7" / "42" / "templates" / "labor", STONE), # 개인 층 + (storage / "7" / "42" / "p1" / "templates" / "labor", STONE), # 프로젝트 — 안 봄 + ): + folder.mkdir(parents=True) + body = {"담은로직": [{"로직": code}]} + (folder / "a.json").write_text(json.dumps(body), encoding="utf-8") + monkeypatch.setattr(cache, "LABOR_ROOTS", (system,)) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + + class Gone: # STONE 을 지운 마스터 + def logic(self, code): + if code == STONE: + raise cache.cm.mf.FormulaError("없음") + + got = cache.labor_errors(Gone()) + assert len(got) == 2 and all(STONE in x for x in got) + assert any("system/a" in x for x in got) and any("templates/a" in x for x in got) From 1baf761c4d35bda147e551d7942142034fa68f60 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 18:01:32 +0900 Subject: [PATCH 33/36] =?UTF-8?q?feat(B05):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=B9=B4=EB=93=9C=20=C2=B7=20?= =?UTF-8?q?=EB=84=A3=EC=9D=84=20=EA=B0=92=20=3D=20=EB=8B=B4=EC=9D=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=EC=A0=84=EB=B6=80=20?= =?UTF-8?q?(39-1c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 차례 도면 · 일위대가 · 면적산출 — 새 ui_template_item_picker_labor.ts(담은 로직 수 · 자재 수 · 기준 합 · 반영 합 · 빠진 입력 · 멈춘 로직) - 자료 laborOf: 복사본이면 복사본 일위대가 + calc · 아니면 원본 층 labor calc(저장 안 함) · 일위대가 없으면 「일위대가 없음」 - 넣을 값 카드 · 편집 = M02_Labor_Values 바로 · 목록 = 담은 로직 입력 전부(logic-lib/inputs) · 값은 복사본 입력값 - ProjectCalc 에 기준합 · 반영합 계 · 시험 test_item_picker_detail 손봄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../tester/helper_item_picker_project.cjs | 38 +++++++- resources/tester/test_item_picker_detail.py | 40 ++++++++- ui_template/ui_template_item_picker.css | 15 ++++ ui_template/ui_template_item_picker_detail.ts | 72 +++++++++------- ui_template/ui_template_item_picker_edit.ts | 19 ++-- ui_template/ui_template_item_picker_labor.ts | 60 +++++++++++++ .../ui_template_item_picker_project.ts | 86 ++++++++++++++++++- 7 files changed, 281 insertions(+), 49 deletions(-) create mode 100644 ui_template/ui_template_item_picker_labor.ts diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 55ad7a213..fcdf04a53 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -4,6 +4,7 @@ * ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403) * ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음) * ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 + * ④ 일위대가(39-1c): 복사본 요약 · 원본 층 계산 요약 · 넣을 값 = 담은 로직 입력 전부 * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. */ const fs = require("fs"); @@ -66,6 +67,21 @@ globalThis.fetch = async (url, init = {}) => { const method = init.method ?? "GET"; const body = init.body ? JSON.parse(init.body) : undefined; state.calls.push({ method, url, body }); + if (url.startsWith("/api/m02/logic-lib/inputs?keys=")) { + state.logicKeys = decodeURIComponent(url.split("=")[1]).split(","); + return reply(200, { 로직: INPUTS.로직, 빠진로직: [] }); + } + if (/^\/api\/m02\/layers\/system\/labor\/rv\/calc$/.test(url)) { + return reply(200, { + 줄: [ + { 로직: "GA1", 까닭: null }, + { 로직: "GA2", 까닭: "입력 없음" }, + ], + 자재: [{ 코드: "MT000008" }], + 기준합: { 계: 500 }, + 반영합: { 계: 1500 }, + }); + } if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) { state.inputsHits += 1; return reply(200, INPUTS); @@ -97,7 +113,13 @@ globalThis.fetch = async (url, init = {}) => { const folder = body.structure.replace("#", "n"); state.folders[folder] = { info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, - parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, + parts: { + 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, + 입력값: null, + 일위대가: { + 줄: { 일위대가번: "SU-000007", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }], 자재: [{}] }, + }, + }, }; return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" }); } @@ -132,6 +154,8 @@ globalThis.fetch = async (url, init = {}) => { { 로직: "GA2", 입력: "지역" }, ], 멈춤: ["GA1"], + 기준합: { 계: 100 }, + 반영합: { 계: 300 }, }); if (method === "GET") { const content = item.parts[part] ?? null; @@ -226,6 +250,18 @@ async function main() { out.calc = await items.calc(info.폴더); out.calcMissing = await items.calc("없는폴더"); + // 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부 + out.laborCopy = await project.laborOf(row(), items, info.폴더); + out.laborOrigin = await project.laborOf(row(), null, null); + out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null); + const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} }); + out.laborValues = { + keys: state.logicKeys, + link: valueData.link.줄.map((l) => `${l.로직}|${l.입력}|${l.자리}`), + shown: model.pickedGroups(valueData.list, valueData.link).length, + values: valueData.values, + }; + // 입력 목록 — 같은 항목은 한 번만 받음 const first = await project.loadInputs(row()); await project.loadInputs(row()); diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index e0b785b12..a5b0bf6fa 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -82,7 +82,7 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out): assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply" assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로 assert out["reapply"]["info"]["마스터바뀜"] is False - assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} + assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300} assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 @@ -146,8 +146,42 @@ def test_선택_입력_카드는_sub2_파일_한_벌(out): assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value) assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): - text = (UI / name).read_text(encoding="utf-8") - assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name + text = (UI / name).read_text(encoding="utf-8") # 모달은 값 카드를 바로 받음(39-1c) + assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name + + +def test_일위대가_요약_복사본_원본_넣을_값_전부(out): + """39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc · + 넣을 값 = 담은 로직 입력 전부.""" + assert out["laborCopy"] == { + "번호": "SU-000007", + "로직": ["GA1", "GA2"], + "자재": 1, + "base": 100, + "reflected": 300, + "gaps": 2, + "stuck": 1, + } + assert out["laborOrigin"] == { + "번호": None, + "로직": ["GA1", "GA2"], + "자재": 1, + "base": 500, + "reflected": 1500, + "gaps": 0, + "stuck": 1, + } + assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404) + values = out["laborValues"] + assert values["keys"] == ["GA1", "GA2"] + assert values["link"] == ["GA1|지역|선택", "GA1|소량감률|선택", "GA2|지역|선택"] + assert values["shown"] == 2 and values["values"] == {"GA1": {}} + + +def test_모달_카드_차례_도면_일위대가_면적(): + detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") + assert 'import("./ui_template_item_picker_labor")' in detail + assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail def test_모달은_원본_층에_쓰는_길을_안_부름(): diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 805e228a6..efe0fa5d9 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -269,3 +269,18 @@ .ui-modal.ui-item-ask { width: min(28rem, 92vw); } + +/* 일위대가 카드(39-1c) — 담은 로직 · 자재 수 · 기준 합 · 반영 합 글 */ +.ui-item-picker__labor-line { + margin: 0 0 var(--spacing-4); + color: var(--color-text-body); + font-size: 13px; +} + +.ui-item-picker__labor-line.is-strong { + font-weight: var(--font-weight-medium); +} + +.ui-item-picker__labor-line.is-warn { + color: var(--color-danger); +} diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index 2f7ba2cf8..681fdc461 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,8 +1,9 @@ /* ============================================================================= * ui_template_item_picker_detail.ts - * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 넷**(도면 · 면적산출 · 재료산출 · 선택 입력). - * 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 - * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출(· 재료산출) · + * 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드 + * (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). + * 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값). * * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 @@ -12,15 +13,12 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; -import { - readInputLinks, - readTemplate, - type InputLinks, -} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; -import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; -import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values"; +import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; +import type { LaborCardHandle } from "./ui_template_item_picker_labor"; import { askFields } from "./ui_template_item_picker_ask"; import { folderLines, @@ -32,7 +30,8 @@ import { type PickerRow, } from "./ui_template_item_picker_data"; import { - loadInputs, + laborOf, + laborValueData, PART_OF, projectItems, sameItem, @@ -118,6 +117,7 @@ const TEXT = { 재료: "재료산출", 자재조합: "자재조합", 입력연결: "선택 입력 자리", + 일위대가: "일위대가", } as Record, }; @@ -137,6 +137,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let infos: ProjectItemInfo[] | null = null; let cards: ItemCardsHandle | null = null; let inputs: InputsValueHandle | null = null; + let labor: LaborCardHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; @@ -301,34 +302,36 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { opts.onEdit({ kind, row, info, items, projectId: opts.projectId }); } - async function paintInputs(row: PickerRow, mine: number): Promise { - if (!inputs) return; - const stop = inputs.loading(); // 카드 안 「불러오는 중」(M02 31-7) + /** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */ + async function paintLabor(row: PickerRow, mine: number): Promise { + if (!inputs || !labor) return; + const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7) try { - const list = await loadInputs(row); - let link: InputLinks | null; - let values: ValueDoc = {}; - if (info && items) { - // 복사본 것(가져온 때 그대로) — 값도 복사본에서 - link = (await items.read(info.폴더, "입력연결")) as InputLinks | null; - values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; - } else { - link = await readInputLinks(layerOf(row), row.key).catch(() => null); - } - stop(); - if (mine === seq) inputs.set({ list, link, values }); + const copy = info && items ? info.폴더 : null; + const summary = await laborOf(row, copy ? items : null, copy); + const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {}; + const data = summary ? await laborValueData(summary.로직, values) : null; + stops.forEach((stop) => stop()); + if (mine !== seq) return; + labor.set(summary); + inputs.set(data); } catch (error) { - stop(); - if (mine === seq) inputs.set(null, why(error), () => void paintInputs(row, seq)); + stops.forEach((stop) => stop()); + if (mine !== seq) return; + const retry = (): void => void paintLabor(row, seq); + labor.set(null, why(error), retry); + inputs.set(null, why(error), retry); } } async function ensureCards(): Promise { - if (cards && inputs) return; - const [cardModule, inputModule] = await Promise.all([ + if (cards && inputs && labor) return; + const [cardModule, inputModule, laborModule] = await Promise.all([ import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), + import("../M02_MasterTemplete/M02_Labor_Values"), + import("./ui_template_item_picker_labor"), ]); + labor = laborModule.createLaborCard(); inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, @@ -337,6 +340,8 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { warnOnFail: () => false, extraCards: [inputs.root], }); + // 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에 + cards.root.insertBefore(labor.root, cards.root.children[1] ?? null); cardsHost.replaceChildren(cards.root); } @@ -349,6 +354,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { cardsHost.replaceChildren(); cards = null; inputs = null; + labor = null; return; } paintHead(row); @@ -365,14 +371,14 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { other = own && !info ? own : null; source = makeSource(row); await ensureCards(); - if (mine !== seq || !cards || !inputs) return; + if (mine !== seq || !cards || !inputs || !labor) return; const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label, material: label }); inputs.setEditLabel(label ?? "선택 입력 수정"); cards.root.hidden = false; paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); - void paintInputs(row, mine); + void paintLabor(row, mine); })(); } diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index 9aa0b4b62..0140c9b1f 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -11,12 +11,12 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_el import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing"; -import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values"; import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditRequest } from "./ui_template_item_picker_detail"; import { itemName } from "./ui_template_item_picker_data"; -import { loadInputs, PART_OF } from "./ui_template_item_picker_project"; +import { laborValueData, loadInputs, PART_OF } from "./ui_template_item_picker_project"; export interface EditView { root: HTMLElement; @@ -146,15 +146,18 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi }); } + /** 넣을 값 — 복사본 일위대가 담은 로직 입력 전부(39-1c) */ async function mountInputs(): Promise { - const [{ createInputsValueCard }, list, link] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), - loadInputs(row), - linkOf(), + const [{ createInputsValueCard }, labor, values] = await Promise.all([ + import("../M02_MasterTemplete/M02_Labor_Values"), + items.read(info.폴더, "일위대가") as Promise<{ + 줄?: { 담은로직?: { 로직?: string }[] }; + } | null>, + items.read(info.폴더, "입력값") as Promise, ]); - const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + const logics = (labor?.줄?.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean); card = createInputsValueCard({ mode: "value", onChange: () => setDirty(true) }); - card.set({ list, link, values }); + card.set(await laborValueData(logics, values ?? {})); body.replaceChildren( el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }), ); diff --git a/ui_template/ui_template_item_picker_labor.ts b/ui_template/ui_template_item_picker_labor.ts new file mode 100644 index 000000000..69f14eaf1 --- /dev/null +++ b/ui_template/ui_template_item_picker_labor.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * ui_template_item_picker_labor.ts + * 항목 고르기 모달 「일위대가」 카드(계획서 39-1c) — 담은 로직 수 · 자재 수 · 기준 합 · 반영 합 글. + * 보기만 · 고치기는 원본 층 일위대가 상세. 일위대가 보기 부품(39-3 C)이 오면 몸을 그것으로 바꿈. + * 자료 = `ui_template_item_picker_project.laborOf`(복사본이면 복사본 계산 · 아니면 원본 층 계산). + * 상세(`ui_template_item_picker_detail.ts`)가 카드를 받을 때 함께 `import()`. + * ========================================================================== */ + +import { el } from "./ui_template_elements"; +import { cardNote, cardShell } from "../M02_MasterTemplete/M02_MasterTemplete_CardShell"; +import type { LaborSummary } from "./ui_template_item_picker_project"; + +const TEXT = { + title: "일위대가", + none: "일위대가 없음", + failed: "일위대가를 못 읽음 — {value}", + logics: "담은 로직 {value}개", + materials: "자재 {value}개", + base: "기준 합 {value}원", + reflected: "반영 합 {value}원", + gaps: "빠진 입력 {value}개", + stuck: "멈춘 로직 {value}개", +}; + +const fill = (text: string, value: string | number): string => + text.replace("{value}", String(value)); +const won = (value: number | null): string => + value === null ? "—" : Math.round(value).toLocaleString(); + +export interface LaborCardHandle { + root: HTMLElement; + loading: () => () => void; + /** `null` = 일위대가 없음 · `note` 가 있으면 못 읽은 까닭 */ + set: (summary: LaborSummary | null, note?: string, retry?: () => void) => void; +} + +export function createLaborCard(): LaborCardHandle { + const number = el("span", { className: "m02-struct__no" }); + const body = el("div", { className: "m02-struct__sheet ui-item-picker__labor" }); + const shell = cardShell({ title: TEXT.title, sub: [number], body }); + const line = (text: string, className = ""): HTMLElement => + el("p", { className: `ui-item-picker__labor-line ${className}`.trim(), text }); + + return { + root: shell.root, + loading: () => shell.loading(), + set: (summary, note, retry) => { + number.textContent = summary?.번호 ?? ""; + if (note && retry) return shell.fail(fill(TEXT.failed, note), retry); + if (!summary) return void body.replaceChildren(cardNote(TEXT.none)); + body.replaceChildren( + line(`${fill(TEXT.logics, summary.로직.length)} · ${fill(TEXT.materials, summary.자재)}`), + line(fill(TEXT.base, won(summary.base))), + line(fill(TEXT.reflected, won(summary.reflected)), "is-strong"), + ...(summary.gaps ? [line(fill(TEXT.gaps, summary.gaps), "is-warn")] : []), + ...(summary.stuck ? [line(fill(TEXT.stuck, summary.stuck), "is-warn")] : []), + ); + }, + }; +} diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 27366224d..b3f46bb7a 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -9,20 +9,24 @@ * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} - * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) + * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합) * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` + * 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchItemInputs, + type InputLinks, type ItemInputs, } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; /** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ export type ProjectPart = - "도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재"; + "도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재" | "일위대가"; /** 원본 종류 → 복사본 부분 */ export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = { @@ -66,11 +70,13 @@ export interface CreatedItem { summary_item: { layer: string; owner: string | null; key: string; name: string }; } -/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ +/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 · 일위대가 기준합 · 반영합 계 */ export interface ProjectCalc { total: number; gaps: number; stuck: number; + base: number | null; + reflected: number | null; } interface Reply { @@ -217,11 +223,19 @@ export function projectItems(projectId: string): ProjectItems { calc: async (folder) => { const reply = await call(`${base}/${enc(folder)}/calc`, post({})); if (reply.status !== 200) return null; - const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] }; + const got = reply.body as { + 계?: number; + 빠진입력?: unknown[]; + 멈춤?: unknown[]; + 기준합?: { 계?: number }; + 반영합?: { 계?: number }; + }; return { total: Number(got.계 ?? 0), gaps: got.빠진입력?.length ?? 0, stuck: got.멈춤?.length ?? 0, + base: got.기준합?.계 ?? null, + reflected: got.반영합?.계 ?? null, }; }, }; @@ -242,3 +256,67 @@ export function loadInputs(row: PickerRow): Promise { made.catch(() => inputsCache.delete(id)); return made; } + +/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */ + +/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */ +export interface LaborSummary { + 번호: string | null; + /** 담은 로직 키(차례 그대로) — 넣을 값 목록을 받는 데도 씀 */ + 로직: string[]; + 자재: number; + base: number | null; + reflected: number | null; + gaps: number; + stuck: number; +} + +interface LaborCopy { + 줄?: { 일위대가번?: string; 담은로직?: { 로직?: string }[]; 자재?: unknown[] }; +} + +/** 그 항목의 일위대가 — 복사본(`folder`)이 있으면 그것 · 없으면 원본 층 열 · 일위대가가 없으면 null */ +export async function laborOf( + row: PickerRow, + items: ProjectItems | null, + folder: string | null, +): Promise { + if (items && folder) { + const copy = (await items.read(folder, "일위대가")) as LaborCopy | null; + if (!copy?.줄) return null; + const got = await items.calc(folder); + return { + 번호: copy.줄.일위대가번 ?? null, + 로직: (copy.줄.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean), + 자재: copy.줄.자재?.length ?? 0, + base: got?.base ?? null, + reflected: got?.reflected ?? null, + gaps: got?.gaps ?? 0, + stuck: got?.stuck ?? 0, + }; + } + const got = await calcLabor(layerOf(row), row.열, null).catch(() => null); + if (!got) return null; + return { + 번호: null, + 로직: got.줄.map((line) => line.로직), + 자재: got.자재.length, + base: got.기준합.계 ?? null, + reflected: got.반영합.계 ?? null, + gaps: 0, + stuck: got.줄.filter((line) => line.까닭).length, + }; +} + +/** 넣을 값 카드 자료 — 담은 로직 입력 전부(자리 「선택」 으로 감싸 값 카드가 다 보임) */ +export async function laborValueData( + logics: string[], + values: ValueDoc, +): Promise<{ list: ItemInputs; link: InputLinks; values: ValueDoc }> { + const got = await fetchLogicInputs(logics); + const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 }; + const 줄 = list.로직.flatMap((logic) => + logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })), + ); + return { list, link: { 판: null, 줄 }, values }; +} From cbcfc184367fe903ce6138b99e288dc44bd18c4a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 18:03:35 +0900 Subject: [PATCH 34/36] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0=20=EA=B2=80=EC=82=AC=20=3D=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=B8=B5=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=AC=B8=EC=84=9C=20=EB=8B=B4=EC=9D=80=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20+=20=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=93=B0=EB=8A=94=20=EA=B3=B3=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=C2=B7=20=EC=A1=B0=ED=95=A9=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B8(PUT)=20=EC=9D=80=ED=87=B4(39-1d)?= 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_013rJwJMVL4EE31y77TB11AP --- M01_MasterData/M01_MasterData_Store_Cache.py | 32 +++++++++++++- .../M02_MasterTemplete_Router_Combos.py | 12 ------ resources/tester/test_m01_make.py | 43 +++++++++++++++++++ resources/tester/test_m02_combo_layers.py | 9 ++-- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index c704de769..cca474f97 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -217,7 +217,37 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) out += cm.check_links(whole) - return out + cm.mcb.check_all(files, whole) + return out + cm.mcb.check_all(files, whole) + labor_errors(whole) + + +def _labor_files() -> list[tuple[str, Path]]: + """모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일).""" + from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 + + root = layers.storage_root() + found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))] + found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))] + personal = sorted(root.glob("*/*/templates/labor/*.json")) + return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal] + + +def labor_errors(whole) -> list[str]: + """일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림. + 전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌.""" + out = [] + for where, path in _labor_files(): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem + for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []: + key = str(one.get("로직") or "") if isinstance(one, dict) else "" + try: + whole.logic(key) + except cm.mf.FormulaError: + out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") + return out def warm(folder: Path) -> threading.Thread: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 543a4905b..ef603b5eb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -138,18 +138,6 @@ def post_combo( return _call(folder, combos.create, body.combo, body.판 or None) -@router.put("/layers/{layer}/combos/{key}") -def put_combo( - layer: Layer, - key: str, - body: ComboBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit, key, body.combo, body.판 or "") - - @router.delete("/layers/{layer}/combos/{key}") def delete_combo( layer: Layer, diff --git a/resources/tester/test_m01_make.py b/resources/tester/test_m01_make.py index 3ccb36cc2..c0847a8bd 100644 --- a/resources/tester/test_m01_make.py +++ b/resources/tester/test_m01_make.py @@ -176,3 +176,46 @@ def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0 listed = _get(client, "/api/m01/logics", owner="공용")["logics"] assert made["key"] in [x["키"] for x in listed] + + +def test_일위대가가_담은_로직은_못_지운다(client: TestClient, tmp_path: Path, monkeypatch) -> None: + """39-1d — 로직 지우기 검사 = 모든 층(마스터 · 회사 · 개인) labor 담은로직 + 실무문서(마스터 + 일위대가 조합) · 쓰는 곳을 알림 · 다 빼면 지워짐(임시 storage · 임시 마스터).""" + import json + + from config import config_system + from M02_MasterTemplete import M02_Template_Layers as layers + + system, storage = tmp_path / "system", tmp_path / "storage" + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) + key = made["key"] + docs = { + "회사 3": storage / "3/templates/labor/item1.json", + "개인 3/15": storage / "3/15/templates/labor/item2.json", + "마스터": system / "labor/item3.json", + } + for at, path in enumerate(docs.values()): + path.parent.mkdir(parents=True) + doc = {"일위대가번": f"SU-00000{at}", "담은로직": [{"로직": SAMPLE}, {"로직": key}]} + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + combos = store.FOLDER / store.mcb.FILE + raw = json.loads(combos.read_text(encoding="utf-8")) + raw["줄"].append({**raw["줄"][0], "키": "UA999999", "담은로직": [{"로직": key, "메모": None}]}) + combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + + def delete(want: int) -> dict: + version = _get(client, "/api/m01/logic", key=key)["version"] + return _post(client, "/api/m01/logic/delete", {"key": key, "version": version}, want) + + errors = " ".join(delete(422)["detail"]["errors"]) + for where in docs: + assert f"일위대가 {where} 「SU-" in errors and key in errors # 어디서 쓰는지 알림 + assert store.mcb.FILE in errors # 실무문서(마스터 일위대가 조합) + for path in docs.values(): # 일위대가에서 뺌 — 실무문서만 남음 + path.write_text(json.dumps({"담은로직": [{"로직": SAMPLE}]}), encoding="utf-8") + assert "일위대가 " not in " ".join(delete(422)["detail"]["errors"]) + raw["줄"].pop() + combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + assert delete(200)["file"] == make.OWN # 쓰는 곳 0 = 지워짐 diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 9bcbfb596..d219dd1a4 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -60,7 +60,7 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: def test_조합_길은_시스템_관리자만(world): - """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직은 로그인만.""" world["client"].app.dependency_overrides.pop(combos_router._door) for role in ("USER", "ADMIN"): client = _as(world, role) @@ -149,10 +149,9 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world): branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key)) assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보 url = f"{URL.format('personal')}/{first['키']}" - assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409 - now = _list(me, "personal")["판"] - res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now}) - assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B + assert ( + me.put(url, json={"combo": _combo("원본 고침")}).status_code == 405 + ) # 조합 고치기 은퇴(39-1d) # ── 나무 ───────────────────────────────────────────────────────────── From 5b4aed1b2088bb4af068028d5e264b9bbf73a9c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 18:03:54 +0900 Subject: [PATCH 35/36] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=84=B8=EB=A5=BC=20UI=5FMain=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20labor=20=EB=A1=9C=20=EC=97=B6=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=88=EB=9E=98=20=EB=B6=80=ED=92=88=20=C2=B7=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EB=A7=8C=20=EB=B0=94=EB=80=8C=EB=A9=B4=20?= =?UTF-8?q?=EC=A7=9D=20=EC=85=88=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B1=ED=95=A8(39-3=20C7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI_Main: 종류 labor 갈래 한 곳(mountLaborDetail · 「면적산출에서 잇기」 = 물은 뒤 그 열 면적산출) · 번호 칸 일위대가번 - Api_Fetch: Kind 에 labor - 새 Labor_View.ts(createLaborView — 면적산출 오른쪽 패널 · 일위대가 카드용 · 담은 로직 + 자재목록 · show · setQty · onRow) - Labor_Detail: 면적산출 연결 칸 → 「수량 F14 = …」 · 수량만 바뀌면 Labor_Calc.settle 로 서버 없이 · 손 입력 숫자는 수로 - Labor_Api: calc 답 모양은 Labor_Calc.ts 것을 씀 · normalizeLabor · areaLinks - 자재 표 머리 「단위」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Api_Fetch.ts | 2 +- .../M02_MasterTemplete_Labor.css | 13 ++ .../M02_MasterTemplete_Labor_Api.ts | 90 +++++++---- .../M02_MasterTemplete_Labor_Detail.ts | 81 +++++----- .../M02_MasterTemplete_Labor_Diagram.ts | 10 +- .../M02_MasterTemplete_Labor_Input.ts | 7 +- .../M02_MasterTemplete_Labor_Rows.ts | 20 ++- .../M02_MasterTemplete_Labor_View.ts | 152 ++++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 15 ++ 9 files changed, 303 insertions(+), 87 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6ac97e28e..0c89cac51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "material"; +export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor"; export interface TemplateInfo { 종류: Kind; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 6f435b42d..a5a234504 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -123,3 +123,16 @@ gap: var(--spacing-8); margin-top: var(--spacing-12); } + +/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */ +.m02-labview { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-labview__title { + margin: 0; + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 026566821..6a49e2f5d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,6 +12,7 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc"; import { getActiveLayer, getActiveOwner, @@ -254,39 +255,64 @@ export interface LaborDoc { 자재: LaborMat[]; 계산값?: unknown; } -export interface LaborCost { - 노무비: number; - 재료비: number; - 경비: number; - 계: number; +/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ +export function normalizeLabor(doc: unknown, column: string): LaborDoc { + const d = (doc && typeof doc === "object" ? doc : {}) as Partial; + return { + ...d, + 종류: "일위대가", + 열: d.열 ?? column, + 단위: d.단위 ?? null, + 담은로직: (d.담은로직 ?? []).map((r) => ({ + 로직: r.로직, + 메모: r.메모 ?? null, + 입력값: r.입력값 ?? {}, + 수량: r.수량 ?? null, + })), + 자재: (d.자재 ?? []).map((m) => ({ + 코드: m.코드, + 이름: m.이름 ?? null, + 규격: m.규격 ?? null, + 단위: m.단위 ?? null, + 수량: m.수량 ?? null, + })), + }; } -/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */ -export interface LaborCalcLine { - 로직: string; - 분모: string | null; - 배: number | null; - 수량: number | null; - 수량출처: string | null; - 기준: LaborCost | null; - 반영: LaborCost | null; - 결과?: number | null; - 까닭: string | null; -} -export interface LaborCalcMat { - 코드: string; - 기준단가: number | null; - 수량: number | null; - 수량출처: string | null; - 반영: number | null; - 까닭: string | null; -} -export interface LaborCalc { - 줄: LaborCalcLine[]; - 자재: LaborCalcMat[]; - 기준합: Partial; - 반영합: Partial; + +/** 면적산출 문서의 알약 → {칸, 값}(식 칸 = 서버 `계산값` · 그 밖 = 칸 값 · 수만) — 서버 `area_values` 와 같은 규칙 */ +export function areaLinks(area: unknown): Map { + const out = new Map(); + const doc = (area && typeof area === "object" ? area : {}) as { + 시트?: { + id?: string; + 칸?: Record; + 연결?: { 키?: string; 칸?: string }[]; + }[]; + 계산값?: Record>; + }; + for (const sheet of doc.시트 ?? []) { + const solved = doc.계산값?.[sheet.id ?? ""] ?? {}; + for (const link of sheet.연결 ?? []) { + if (!link.키 || !link.칸) continue; + const raw = solved[link.칸]?.수 ?? sheet.칸?.[link.칸]?.값; + const n = + raw === null || raw === undefined || raw === "" + ? NaN + : Number(String(raw).replace(/,/g, "")); + out.set(link.키, { 칸: link.칸, 값: Number.isFinite(n) ? n : null }); + } + } + return out; } +/** calc 답 모양 · 수량 · 반영 짝 셈 = `M02_MasterTemplete_Labor_Calc.ts`(sub7 · ↔ `M02_Labor_Doc.py`) */ +export type { + CalcLine as LaborCalcLine, + CalcMaterial as LaborCalcMat, + LaborCalc, + Money as LaborCost, +} from "./M02_MasterTemplete_Labor_Calc"; + /** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ const laborAt = (layer: Layer, column: string, tail: string): string => { const owner = layer === getActiveLayer() ? getActiveOwner() : null; @@ -294,13 +320,13 @@ const laborAt = (layer: Layer, column: string, tail: string): string => { return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; }; -/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */ +/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = {알약 키: 칸 값} 바꿔 끼우기 */ export const calcLabor = ( layer: Layer, column: string, doc: LaborDoc | null, 수량?: Record, -): Promise => +): Promise => request(laborAt(layer, column, "/calc"), "POST", { ...(doc ? { doc } : {}), ...(수량 ? { 수량 } : {}), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index dfddeb889..33f599bfe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -11,10 +11,13 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; -import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { + areaLinks, calcLabor, fetchLogicInputs, + normalizeLabor, type LaborCalc, type LaborDoc, type LaborRow, @@ -26,7 +29,7 @@ import { } from "./M02_MasterTemplete_Labor_Diagram"; import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; @@ -35,30 +38,6 @@ import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; -/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ -export function normalizeLabor(doc: unknown, column: string): LaborDoc { - const d = (doc && typeof doc === "object" ? doc : {}) as Partial; - return { - ...d, - 종류: "일위대가", - 열: d.열 ?? column, - 단위: d.단위 ?? null, - 담은로직: (d.담은로직 ?? []).map((r) => ({ - 로직: r.로직, - 메모: r.메모 ?? null, - 입력값: r.입력값 ?? {}, - 수량: r.수량 ?? null, - })), - 자재: (d.자재 ?? []).map((m) => ({ - 코드: m.코드, - 이름: m.이름 ?? null, - 규격: m.규격 ?? null, - 단위: m.단위 ?? null, - 수량: m.수량 ?? null, - })), - }; -} - export interface LaborDetailOptions { layer: Layer; column: string; @@ -88,6 +67,11 @@ export function mountLaborDetail( let seq = 0; let timer = 0; let alive = true; + /** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */ + let links = new Map(); + const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); + const given = (): Record => + Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); const logicHost = el("div"); const matHost = el("div"); @@ -126,14 +110,14 @@ export function mountLaborDetail( const draw = (): void => { logicHost.replaceChildren( logicTable( - { rows: cur.담은로직, logics, calc, fail }, + { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, ), ); matHost.replaceChildren( matTable( - { mats: cur.자재, calc, fail }, + { mats: cur.자재, calc, fail, cells: cells() }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); @@ -160,9 +144,20 @@ export function mountLaborDetail( }, 150); }; - const changed = (): void => { + /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ + const changed = (qtyOnly = false): void => { opt.onChanged(JSON.stringify(cur) !== saved); - recalc(); + if (qtyOnly && calc) { + calc = settle(calc, cur, given()); + draw(); + } else recalc(); + }; + + /** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */ + const linkedText = (pill: string | null | undefined, unit: string): string | null => { + const at = pill ? links.get(pill) : undefined; + if (!at) return null; + return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; }; /** 아직 안 받은 로직 입력 목록만 받음 */ @@ -203,16 +198,18 @@ export function mountLaborDetail( memo: row.메모, seat: { qty: row.수량, - linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null, + linked: linkedText(line?.알약, line?.분모 ?? ""), unit: line?.분모 ?? "", }, readOnly: !edit, goArea: opt.goArea, done: (next) => { - row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값); + row.입력값 = values; row.메모 = next.메모; row.수량 = next.수량; - changed(); + changed(qtyOnly); }, }); } @@ -224,17 +221,14 @@ export function mountLaborDetail( `${mat.코드} ${mat.이름 ?? ""}`.trim(), { qty: mat.수량, - linked: - line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null - ? `${line.수량출처} = ${line.수량}` - : null, + linked: linkedText(line?.알약, mat.단위 ?? ""), unit: mat.단위 ?? "", }, !edit, opt.goArea, (qty) => { mat.수량 = qty; - changed(); + changed(true); }, ); } @@ -248,7 +242,7 @@ export function mountLaborDetail( if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); - void loadLogics().then(changed); + void loadLogics().then(() => changed()); }), }); const addMat = createButton({ @@ -308,7 +302,14 @@ export function mountLaborDetail( }), ); draw(); - void loadLogics().then(() => { + // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 + void Promise.all([ + loadLogics(), + readTemplate(opt.layer, "area", opt.column, null).then( + (got) => (links = areaLinks(got.문서)), + () => undefined, + ), + ]).then(() => { if (alive) recalc(); }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 9125b3042..fb987d593 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -105,11 +105,11 @@ export interface LaborDiagramSource { 줄: { 로직: string; 기준: Money | null; - 반영: Money | null; - 결과?: number | null; - 까닭: string | null; + 반영?: Money | null; + 결과?: unknown; + 까닭?: string | null; }[]; - 자재: { 기준단가: number | null; 반영: number | null }[]; + 자재: { 기준단가: number | null; 반영?: number | null }[]; } | null; /** 담은 자재 수 — 0 이면 자재 카드 없음 */ mats: number; @@ -157,7 +157,7 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { unit: "", inputs: [], out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null, - ...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}), + ...(mats ? { basis: mats.every((m) => typeof m.반영 === "number") ? "반영" : "기준" } : {}), material: true, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index e5d913bff..13afe0850 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -137,7 +137,12 @@ export function openLaborInput(opt: LaborInputOptions): void { if (isBad) bad.add(input.이름); else bad.delete(input.이름); if (value === null) delete draft[input.이름]; - else draft[input.이름] = value; + // 고르기 · 범위 없는 칸도 수로 읽히면 수로 둠(M01 넣을 값과 같게) + else + draft[input.이름] = + typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) + ? Number(value) + : value; }); field.control.disabled = opt.readOnly; field.control.setAttribute("data-labor-input", input.이름); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index f8c637ccc..9e3bc3fbe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -33,16 +33,17 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */ +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */ export function qtyText( - line: { 수량: number | null; 수량출처: string | null } | undefined, + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, + cells: ReadonlyMap = new Map(), ): string { if (!line || line.수량 === null || line.수량 === undefined) return ""; const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; - if (!line.수량출처 || line.수량출처 === "손") - return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; - return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`; + if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; + const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); + return `${tc("Labor_Qty")} ${cell} = ${v}`; } /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ @@ -100,6 +101,8 @@ export interface LogicTableData { /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ calc: LaborCalc | null; fail?: string | null; + /** 알약 키 → 이은 면적산출 칸(「B6」) */ + cells?: ReadonlyMap; } /** 담은 로직 표 — 보기 갈래는 `on` 없이 */ @@ -114,7 +117,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const line = lineOf(row.로직); const unit = logic?.결과단위 ?? ""; const values = valueLine(logic?.입력 ?? [], row.입력값); - const qty = qtyText(line, line?.분모 ?? ""); + const qty = qtyText(line, line?.분모 ?? "", data.cells); const note = [values.text, qty].filter(Boolean).join(" · "); const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = (label: string, run: () => void): HTMLElement => @@ -210,6 +213,7 @@ export interface MatTableData { mats: LaborMat[]; calc: LaborCalc | null; fail?: string | null; + cells?: ReadonlyMap; } /** 자재목록 표 — 보기 갈래는 `on` 없이 */ @@ -240,7 +244,7 @@ export function matTable(data: MatTableData, on?: Omit): HT el("td", { className: "m02-labrow__num", text: qty, - attrs: { title: line?.수량출처 ?? "" }, + attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) }, }), el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), el("td", { className: "m02-labrow__num", text: show(line?.반영) }), @@ -271,7 +275,7 @@ export function matTable(data: MatTableData, on?: Omit): HT tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), - tc("Col_Unit"), + tc("Labor_Unit"), tc("Labor_Qty"), tc("Labor_BasePrice"), tc("Labor_AppliedSum"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts new file mode 100644 index 000000000..35d805140 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_View.ts + * 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영). + * 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`). + * · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만) + * · `setQty({알약 키: 칸 값})` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 바로 곱함 + * (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」) + * · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + areaLinks, + calcLabor, + fetchLogicInputs, + normalizeLabor, + type LaborCalc, + type LaborDoc, +} from "./M02_MasterTemplete_Labor_Api"; +import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; + +export interface LaborViewRow { + kind: "logic" | "material"; + /** 로직 키 · 자재 코드 */ + key: string; + /** 면적산출 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — calc 전이면 null */ + pill: string | null; + name: string; + unit: string; +} + +export interface LaborViewHandle { + root: HTMLElement; + /** 그 열 일위대가를 읽어 그림 — `수량` = 저장 안 한 면적 칸 값(없으면 저장된 면적산출) */ + show: (layer: Layer, column: string | null, 수량?: Record) => Promise; + /** 면적 칸 값이 바뀜(알약 값 전부) — 받은 기준에 바로 곱함(서버 안 부름) */ + setQty: (수량: Record) => void; + /** 지금 문서(없으면 null) */ + doc: () => LaborDoc | null; + destroy: () => void; +} + +export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle { + const root = el("div", { className: "m02-labview" }); + const logics = new Map(); + let doc: LaborDoc | null = null; + let calc: LaborCalc | null = null; + let fail: string | null = null; + let cells = new Map(); + let seq = 0; + + const paint = (): void => { + if (!doc) return; + const d = doc; + const pick = (row: LaborViewRow): (() => void) | undefined => + onRow ? () => onRow(row) : undefined; + const logicOpen = (i: number): void => { + const row = d.담은로직[i]; + const line = calc?.줄.find((l) => l.로직 === row.로직); + pick({ + kind: "logic", + key: row.로직, + pill: line?.알약 ?? null, + name: logics.get(row.로직)?.이름 ?? row.로직, + unit: line?.분모 ?? "", + })?.(); + }; + const matOpen = (i: number): void => { + const mat = d.자재[i]; + const line = calc?.자재.find((m) => m.코드 === mat.코드); + pick({ + kind: "material", + key: mat.코드, + pill: line?.알약 ?? null, + name: mat.이름 ?? mat.코드, + unit: mat.단위 ?? "", + })?.(); + }; + root.replaceChildren( + el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }), + logicTable( + { rows: d.담은로직, logics, calc, fail, cells }, + onRow ? { open: logicOpen } : undefined, + ), + el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }), + matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined), + ); + }; + + const show = async ( + layer: Layer, + column: string | null, + 수량?: Record, + ): Promise => { + const mine = ++seq; + doc = null; + calc = null; + fail = null; + if (!column) return void root.replaceChildren(); + root.replaceChildren(el("p", { className: "m02-labrow__muted", text: tc("Loading") })); + try { + const got = await readTemplate(layer, "labor", column, null); + if (mine !== seq) return; + doc = normalizeLabor(got.문서, column); + const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + const [inputs, area] = await Promise.all([ + fetchLogicInputs(want), + readTemplate(layer, "area", column, null).then( + (a) => a.문서, + () => null, + ), + ]); + if (mine !== seq) return; + for (const l of inputs.로직) logics.set(l.로직, l); + cells = new Map([...areaLinks(area)].map(([k, v]) => [k, v.칸])); + paint(); // 금액 「…」 + calc = await calcLabor(layer, column, null, 수량); + } catch (error) { + if (mine !== seq) return; + if (!doc) { + root.replaceChildren( + el("p", { + className: "m02-labrow__empty", + text: error instanceof Error ? error.message : tc("Load_Failed"), + }), + ); + return; + } + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + if (mine === seq) paint(); + }; + + return { + root, + show, + setQty: (수량) => { + if (!doc || !calc) return; + calc = settle(calc, doc, 수량); + paint(); + }, + doc: () => doc, + destroy: () => { + seq += 1; + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 522751dc9..4675ae01c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -68,6 +68,7 @@ const NUMBER_FIELD: Partial> = { structure: "도번", area: "면적번", material: "재료번", + labor: "일위대가번", }; // 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음 // 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를 @@ -451,6 +452,20 @@ export function createMain( editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy }; return; } + if (at.kind === "labor") { + // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 + const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + editor = mountLaborDetail(host, doc, { + layer: at.layer, + column: at.name, + readOnly, + onChanged: (d) => (dirty = d && !readOnly), + goArea: async () => { + if (await confirmLeave()) void open({ ...at, kind: "area" }); + }, + }); + return; + } if (at.kind === "area" || at.kind === "material") { // 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을 // 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리) From a97ad41c47f1d240da5435be3e307263696ffe60 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 18:40:55 +0900 Subject: [PATCH 36/36] =?UTF-8?q?fix(M02):=2039=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=92=A4=20=EC=97=87=EA=B0=88=EB=A6=BC=20=E2=80=94=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=ED=95=A9=20=EC=B9=B8=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=20=EC=9E=88=EC=9D=8C(picker)=20=C2=B7=20?= =?UTF-8?q?=EC=83=88=20=EC=97=B4=20=3D=20=EB=B9=88=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20Structure.ts=20readTemplate=20?= =?UTF-8?q?=EB=BA=8C=20=EC=8B=9C=ED=97=98=20=EB=A7=9E=EC=B6=A4?= 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_01BVPp2o6Mh2FZJr72yW3ofZ --- resources/tester/test_m02_item_list.py | 4 +++- resources/tester/test_m02_structure_panel.py | 2 +- ui_template/ui_template_item_picker_project.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 63b809bd7..ccdfa1f68 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -75,7 +75,9 @@ def test_서버_목록_칸_수정일_labor(world): ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"]) assert set(ditch["수정일"]) >= {"structure", "area"} assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO - assert ditch["labor"] is None # 일위대가 파일 없음 + assert ( + ditch["labor"]["로직수"] == 0 and ditch["labor"]["합계"] is None + ) # 새 열 = 빈 일위대가(39-1b) (root / "labor").mkdir(exist_ok=True) doc = { "일위대가번": "SU-000001", diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 51b58e851..1df781508 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -46,7 +46,7 @@ def test_소스가_700줄을_넘지_않음(): [ ( "M02_MasterTemplete_Api_Fetch.ts", - ["createStructure", "fetchStructureNumbers", "readTemplate"], + ["createStructure", "fetchStructureNumbers"], # readTemplate 은 39-4 에서 뺌 PANEL, ), # 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함 diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index b3f46bb7a..ef9a68c3e 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -301,8 +301,8 @@ export async function laborOf( 번호: null, 로직: got.줄.map((line) => line.로직), 자재: got.자재.length, - base: got.기준합.계 ?? null, - reflected: got.반영합.계 ?? null, + base: got.기준합?.계 ?? null, + reflected: got.반영합?.계 ?? null, gaps: 0, stuck: got.줄.filter((line) => line.까닭).length, };