From 12a60baeeb8965eb14e615bd588b37a1ce681132 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:35:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=9D=90=EB=A6=84=EB=8F=84=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=ED=8C=90=20=EC=A0=95=ED=95=A8?= 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_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/흐름도_정함.md | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_정함.md b/docs/raw/M01_쉬운단가/흐름도_정함.md new file mode 100644 index 000000000..25134226b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_정함.md @@ -0,0 +1,38 @@ +# 단가산출 흐름도 시험판 — 정함 (PLAN 35-9 · 브레인 · 10-01 17시 무렵) + +사용자 지시(그대로): 「전체에 대해 너가 제어해서 시험판을 하나 만들어봐. 이때 이전에 검토하였던 단가산출 다이어그램(시험)을 제외한 다른 시험판 들은 삭제해줘. 이번에 만드는 시험판도 신규 폴더에 넣어서 작업해줘. 필요없으면 바로 삭제할수 있게. … 너가 끝까지 구현하고 검증까지 진행해봐. 다시말하지만 자유도 높고 내가 이해하기 쉬운 화면을 만드는게 중요해.」 · 「데이터의 흐름과 값의 연결에 집중해야해. 이것만 잘 연결되면 단가산출 로직들을 만드는건 쉽게 해결될거야.」 +계획 = PLAN.md 35-8(a~o) 전체 · 근거 = 같은 폴더 `원가다이어그램_정함.md`(1판 약속 — 그대로 이어받음). + +## 목적 + +값 하나가 **어디서 생겨(출처) · 어떤 길과 규칙을 지나(선 · 선 규칙 · 함수) · 어디로 가는지(도착)** 를 사용자가 자유롭게 만들고 · 한눈에 따라가고 · 고칠 수 있게. 원가 숫자가 아니라 이 흐름이 검증 기준. + +## 정함 + +1. 폴더 `M02_MasterTemplete/DataFlow/` · 파일 머리 `M02_DF_` · 서버 길 `/api/m02/df/*` · 키 `DF-0001` · 저장 · 임시 = `_data/`(git 밖) · 견본 = `_sample/` · 단위 · 금액 표준 처음 값 = `_standard/` · 폴더째 지우면 끝. 1판(CostFlow)을 옮겨 시작하되 import 로 기대지 않음(CostFlow 는 그대로 남음). +2. 들어가는 곳 = 단가산출 막대 「시험판 ▾」 목록의 「단가산출 흐름도(시험)」 · 전체 화면 페이지. +3. 옛 시험판 셋(EasyCalc · LogicBuilder · SheetLogic) 폴더 · 그 시험 파일 · 막대 목록 항목 · 서버 등록 줄을 지움(사용자 지시) · `로직_자체.json` 의 사용자 로직(GX)은 마스터 자료라 그대로. +4. **흐름 넷** — ① 출처: 입력 상자(그룹 요소 · 표 · 변수 · **재료 자리** · **다른 로직**) ② 길: 선(알약 → 받는 자리) + **선 규칙** 하나(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ③ 처리: 함수 상자(엑셀 · 출력 하나) ④ 도착: 출력 상자(비용 종류 · 값) · 다른 로직의 입력 · 「설계 때 정할 것」. +5. **재료 요소 방식 셋**(재료만) — 고정(품목 하나) · 선택 자리(범위 = 구분 · 상세구분 · 찾는 말 · 기본 품목 · 검증 칸 · 프로젝트에서 고름) · 조건 찾기(범위 + 「규격 / 이름에 [알약] 포함 / 같음」 · 맞음 0 · 여럿 알림) · 알약 이름 = 자리 이름. 다른 그룹은 상자를 늘림. +6. **다른 로직 입력** — 저장한 흐름도 로직(또는 견본)을 입력 상자로 불러 그 출력(비용 · 값)을 내보내는 알약으로 · 그 로직의 「설계 때 정할 것」(변수 · 재료 자리)은 이 상자의 받는 알약으로(이어 주지 않으면 그 로직의 예시값). +7. **선 규칙**(선 하나에 하나 · 없으면 그대로) — 표에서 찾기(조건 칸마다 맞출 알약 · 꺼낼 칸) · 조건 통과(알약 · 같음 · 다름 · 이상 · 이하 · 초과 · 미만 · 값 — 아니면 넘기지 않음) · 바꾸기(× · ÷ · +% · −% · 단위 바꾸기 계수) · 끝수(자리 · 반올림 / 올림 / 버림). 규칙은 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임 · 한 알약에서 나간 조건 선의 겹침 · 어느 것도 안 맞음을 알림. +8. **흐름 따라가기** — 알약 · 선을 고르면 그 값이 온 길(출처 사슬)과 가는 길을 다이어그램에서 칠하고 상세 내용에 줄로 · 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건 · 맞는 재료 없음)을 상자 · 선 위에 표시. +9. **상세 내용**(옛 「고른 상자」) — 상자 · 알약 · 선 무엇을 골라도 바뀜 · 머리 왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆에 알약 이름 · 칸 차례 = 머리 · 설정 · 본문 · 경고. +10. **단위는 고르기** — 치면 걸러진 목록 · 고르기는 사용자 · 손글 안 받음 · 목록 = 단위 표준 + 마스터 결과단위 + 원 · 「-」 = 없음. +11. **단위 표준 · 금액 단위 표준** — 산림품셈 1-2-2(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md`) 「설계서의 단위 및 소수의 표준」(종목 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③ · 소수자리 「-」 = 0 · 단위 「-」 = 없음) + 「금액의 단위표준」(총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 미만 버림) · 시험판 안에서 표로 보고 고침(사용자가 원문 흠을 고침 · 저장 `_data`). +12. **끝수** — 함수 출력 · 출력 상자 · 선 규칙에 소수자리 · 방법 · 단위를 고르면 단위 표준에서 자리 제안(주 ① 반올림 · ② 항목 값 우선) · 출력(비용) 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림. +13. **화면** — 자유 배치 다이어그램(위 · 크게) · 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자 선택 안 됨 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 아래 상세 내용 · 오른쪽 아래 검증 칸(예시값 + 재료 자리 + 다른 로직의 설계 때 정할 것) · 단추 · 칸은 공용 작은 크기(createButton size "sm" · 덮어쓰기 금지) · 쉬운 말 · 밝음 · 어두움. +14. 보고 · 검증 기준 = 흐름(값이 어디서 와서 · 어떤 규칙을 지나 · 어디로 가는지가 화면에서 따라가지는지 · 끊긴 곳이 보이는지 · 몇 걸음에 만드나). 원가 숫자는 내부 확인만. + +## 파일 · 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 0걸음: CostFlow → DataFlow 옮기기(이름 · 길 · 키 · 폴더 · 시험 사본) · 그다음 Model · Eval 넓히기(정함 5 · 6 · 7 · 8 · 12 · 설계 때 정할 것 목록) — 타입 먼저 | +| sub4 | 서버(정함 5 · 6 · 11 · 12) — 재료 자리 · 조건 찾기 단가 · 자재 찾기(쪽 · 띄어쓰기 AND · 규격) · 다른 로직 값 · 설계 때 정할 것 · 표준 표 읽기 · 쓰기 · 저장 다시 셈의 끝수 | +| sub8 | 다이어그램(정함 7 · 8 · 13) — 선 위 규칙 글 · 선 고르기 · 흐름 길 칠하기 · 끊긴 곳 표시 · 마우스 | +| sub2 | 상세 내용 판(정함 7 · 9 · 10 · 12 · 13) — 선 규칙 판 · 알약 판 · 함수 판 손질(작은 단추) · 단위 고르기 공용 파일 `M02_DF_UnitPick.ts` · 끝수 고르기 | +| sub3 | 입력 판(정함 5 · 6 · 10) — 재료 방식 셋 · 범위 · 조건 · 기본 품목 · 자재 찾기 고침 · 다른 로직 입력 | +| sub5 | 출력 판 · 검증 칸(정함 5 · 6 · 11 · 12) — 재료 자리 고르기 · 다른 로직 설계 값 · 출력 끝수 · 금액 표준 기본 · 끊긴 곳 목록 | +| sub6 | 표준 표 · 견본(정함 11 · 견본) — `_standard/단위표준.json` · `금액단위표준.json` · 시험판 안 표 보기 · 고치기 판 · 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) | +| sub1 | 옛 시험판 지우기(정함 3) · 막대 목록 · 들어가는 페이지 · 상세 내용 머리 · 잇기 · 끝 검증(흐름 기준) | From 1930b80aa42446cc78526fa4a5b215cb886bbe1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:36:12 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=95=ED=95=A8=20=E2=80=94=20=EA=B3=B5=EC=9C=A0=20docs?= =?UTF-8?q?=20=EC=82=AC=EB=B3=B8=20=EB=8B=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/M01_쉬운단가/흐름도_정함.md | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_정함.md b/docs/raw/M01_쉬운단가/흐름도_정함.md new file mode 100644 index 000000000..25134226b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_정함.md @@ -0,0 +1,38 @@ +# 단가산출 흐름도 시험판 — 정함 (PLAN 35-9 · 브레인 · 10-01 17시 무렵) + +사용자 지시(그대로): 「전체에 대해 너가 제어해서 시험판을 하나 만들어봐. 이때 이전에 검토하였던 단가산출 다이어그램(시험)을 제외한 다른 시험판 들은 삭제해줘. 이번에 만드는 시험판도 신규 폴더에 넣어서 작업해줘. 필요없으면 바로 삭제할수 있게. … 너가 끝까지 구현하고 검증까지 진행해봐. 다시말하지만 자유도 높고 내가 이해하기 쉬운 화면을 만드는게 중요해.」 · 「데이터의 흐름과 값의 연결에 집중해야해. 이것만 잘 연결되면 단가산출 로직들을 만드는건 쉽게 해결될거야.」 +계획 = PLAN.md 35-8(a~o) 전체 · 근거 = 같은 폴더 `원가다이어그램_정함.md`(1판 약속 — 그대로 이어받음). + +## 목적 + +값 하나가 **어디서 생겨(출처) · 어떤 길과 규칙을 지나(선 · 선 규칙 · 함수) · 어디로 가는지(도착)** 를 사용자가 자유롭게 만들고 · 한눈에 따라가고 · 고칠 수 있게. 원가 숫자가 아니라 이 흐름이 검증 기준. + +## 정함 + +1. 폴더 `M02_MasterTemplete/DataFlow/` · 파일 머리 `M02_DF_` · 서버 길 `/api/m02/df/*` · 키 `DF-0001` · 저장 · 임시 = `_data/`(git 밖) · 견본 = `_sample/` · 단위 · 금액 표준 처음 값 = `_standard/` · 폴더째 지우면 끝. 1판(CostFlow)을 옮겨 시작하되 import 로 기대지 않음(CostFlow 는 그대로 남음). +2. 들어가는 곳 = 단가산출 막대 「시험판 ▾」 목록의 「단가산출 흐름도(시험)」 · 전체 화면 페이지. +3. 옛 시험판 셋(EasyCalc · LogicBuilder · SheetLogic) 폴더 · 그 시험 파일 · 막대 목록 항목 · 서버 등록 줄을 지움(사용자 지시) · `로직_자체.json` 의 사용자 로직(GX)은 마스터 자료라 그대로. +4. **흐름 넷** — ① 출처: 입력 상자(그룹 요소 · 표 · 변수 · **재료 자리** · **다른 로직**) ② 길: 선(알약 → 받는 자리) + **선 규칙** 하나(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ③ 처리: 함수 상자(엑셀 · 출력 하나) ④ 도착: 출력 상자(비용 종류 · 값) · 다른 로직의 입력 · 「설계 때 정할 것」. +5. **재료 요소 방식 셋**(재료만) — 고정(품목 하나) · 선택 자리(범위 = 구분 · 상세구분 · 찾는 말 · 기본 품목 · 검증 칸 · 프로젝트에서 고름) · 조건 찾기(범위 + 「규격 / 이름에 [알약] 포함 / 같음」 · 맞음 0 · 여럿 알림) · 알약 이름 = 자리 이름. 다른 그룹은 상자를 늘림. +6. **다른 로직 입력** — 저장한 흐름도 로직(또는 견본)을 입력 상자로 불러 그 출력(비용 · 값)을 내보내는 알약으로 · 그 로직의 「설계 때 정할 것」(변수 · 재료 자리)은 이 상자의 받는 알약으로(이어 주지 않으면 그 로직의 예시값). +7. **선 규칙**(선 하나에 하나 · 없으면 그대로) — 표에서 찾기(조건 칸마다 맞출 알약 · 꺼낼 칸) · 조건 통과(알약 · 같음 · 다름 · 이상 · 이하 · 초과 · 미만 · 값 — 아니면 넘기지 않음) · 바꾸기(× · ÷ · +% · −% · 단위 바꾸기 계수) · 끝수(자리 · 반올림 / 올림 / 버림). 규칙은 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임 · 한 알약에서 나간 조건 선의 겹침 · 어느 것도 안 맞음을 알림. +8. **흐름 따라가기** — 알약 · 선을 고르면 그 값이 온 길(출처 사슬)과 가는 길을 다이어그램에서 칠하고 상세 내용에 줄로 · 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건 · 맞는 재료 없음)을 상자 · 선 위에 표시. +9. **상세 내용**(옛 「고른 상자」) — 상자 · 알약 · 선 무엇을 골라도 바뀜 · 머리 왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆에 알약 이름 · 칸 차례 = 머리 · 설정 · 본문 · 경고. +10. **단위는 고르기** — 치면 걸러진 목록 · 고르기는 사용자 · 손글 안 받음 · 목록 = 단위 표준 + 마스터 결과단위 + 원 · 「-」 = 없음. +11. **단위 표준 · 금액 단위 표준** — 산림품셈 1-2-2(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md`) 「설계서의 단위 및 소수의 표준」(종목 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③ · 소수자리 「-」 = 0 · 단위 「-」 = 없음) + 「금액의 단위표준」(총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 미만 버림) · 시험판 안에서 표로 보고 고침(사용자가 원문 흠을 고침 · 저장 `_data`). +12. **끝수** — 함수 출력 · 출력 상자 · 선 규칙에 소수자리 · 방법 · 단위를 고르면 단위 표준에서 자리 제안(주 ① 반올림 · ② 항목 값 우선) · 출력(비용) 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림. +13. **화면** — 자유 배치 다이어그램(위 · 크게) · 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자 선택 안 됨 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 아래 상세 내용 · 오른쪽 아래 검증 칸(예시값 + 재료 자리 + 다른 로직의 설계 때 정할 것) · 단추 · 칸은 공용 작은 크기(createButton size "sm" · 덮어쓰기 금지) · 쉬운 말 · 밝음 · 어두움. +14. 보고 · 검증 기준 = 흐름(값이 어디서 와서 · 어떤 규칙을 지나 · 어디로 가는지가 화면에서 따라가지는지 · 끊긴 곳이 보이는지 · 몇 걸음에 만드나). 원가 숫자는 내부 확인만. + +## 파일 · 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 0걸음: CostFlow → DataFlow 옮기기(이름 · 길 · 키 · 폴더 · 시험 사본) · 그다음 Model · Eval 넓히기(정함 5 · 6 · 7 · 8 · 12 · 설계 때 정할 것 목록) — 타입 먼저 | +| sub4 | 서버(정함 5 · 6 · 11 · 12) — 재료 자리 · 조건 찾기 단가 · 자재 찾기(쪽 · 띄어쓰기 AND · 규격) · 다른 로직 값 · 설계 때 정할 것 · 표준 표 읽기 · 쓰기 · 저장 다시 셈의 끝수 | +| sub8 | 다이어그램(정함 7 · 8 · 13) — 선 위 규칙 글 · 선 고르기 · 흐름 길 칠하기 · 끊긴 곳 표시 · 마우스 | +| sub2 | 상세 내용 판(정함 7 · 9 · 10 · 12 · 13) — 선 규칙 판 · 알약 판 · 함수 판 손질(작은 단추) · 단위 고르기 공용 파일 `M02_DF_UnitPick.ts` · 끝수 고르기 | +| sub3 | 입력 판(정함 5 · 6 · 10) — 재료 방식 셋 · 범위 · 조건 · 기본 품목 · 자재 찾기 고침 · 다른 로직 입력 | +| sub5 | 출력 판 · 검증 칸(정함 5 · 6 · 11 · 12) — 재료 자리 고르기 · 다른 로직 설계 값 · 출력 끝수 · 금액 표준 기본 · 끊긴 곳 목록 | +| sub6 | 표준 표 · 견본(정함 11 · 견본) — `_standard/단위표준.json` · `금액단위표준.json` · 시험판 안 표 보기 · 고치기 판 · 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) | +| sub1 | 옛 시험판 지우기(정함 3) · 막대 목록 · 들어가는 페이지 · 상세 내용 머리 · 잇기 · 끝 검증(흐름 기준) | From 5fb3e089b2c29ff64b91b9da50f55a0a529ffb90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:37:11 +0900 Subject: [PATCH 3/5] auto: 2026-10-01 19:37 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/흐름도_정함.md | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_정함.md b/docs/raw/M01_쉬운단가/흐름도_정함.md new file mode 100644 index 000000000..25134226b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_정함.md @@ -0,0 +1,38 @@ +# 단가산출 흐름도 시험판 — 정함 (PLAN 35-9 · 브레인 · 10-01 17시 무렵) + +사용자 지시(그대로): 「전체에 대해 너가 제어해서 시험판을 하나 만들어봐. 이때 이전에 검토하였던 단가산출 다이어그램(시험)을 제외한 다른 시험판 들은 삭제해줘. 이번에 만드는 시험판도 신규 폴더에 넣어서 작업해줘. 필요없으면 바로 삭제할수 있게. … 너가 끝까지 구현하고 검증까지 진행해봐. 다시말하지만 자유도 높고 내가 이해하기 쉬운 화면을 만드는게 중요해.」 · 「데이터의 흐름과 값의 연결에 집중해야해. 이것만 잘 연결되면 단가산출 로직들을 만드는건 쉽게 해결될거야.」 +계획 = PLAN.md 35-8(a~o) 전체 · 근거 = 같은 폴더 `원가다이어그램_정함.md`(1판 약속 — 그대로 이어받음). + +## 목적 + +값 하나가 **어디서 생겨(출처) · 어떤 길과 규칙을 지나(선 · 선 규칙 · 함수) · 어디로 가는지(도착)** 를 사용자가 자유롭게 만들고 · 한눈에 따라가고 · 고칠 수 있게. 원가 숫자가 아니라 이 흐름이 검증 기준. + +## 정함 + +1. 폴더 `M02_MasterTemplete/DataFlow/` · 파일 머리 `M02_DF_` · 서버 길 `/api/m02/df/*` · 키 `DF-0001` · 저장 · 임시 = `_data/`(git 밖) · 견본 = `_sample/` · 단위 · 금액 표준 처음 값 = `_standard/` · 폴더째 지우면 끝. 1판(CostFlow)을 옮겨 시작하되 import 로 기대지 않음(CostFlow 는 그대로 남음). +2. 들어가는 곳 = 단가산출 막대 「시험판 ▾」 목록의 「단가산출 흐름도(시험)」 · 전체 화면 페이지. +3. 옛 시험판 셋(EasyCalc · LogicBuilder · SheetLogic) 폴더 · 그 시험 파일 · 막대 목록 항목 · 서버 등록 줄을 지움(사용자 지시) · `로직_자체.json` 의 사용자 로직(GX)은 마스터 자료라 그대로. +4. **흐름 넷** — ① 출처: 입력 상자(그룹 요소 · 표 · 변수 · **재료 자리** · **다른 로직**) ② 길: 선(알약 → 받는 자리) + **선 규칙** 하나(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ③ 처리: 함수 상자(엑셀 · 출력 하나) ④ 도착: 출력 상자(비용 종류 · 값) · 다른 로직의 입력 · 「설계 때 정할 것」. +5. **재료 요소 방식 셋**(재료만) — 고정(품목 하나) · 선택 자리(범위 = 구분 · 상세구분 · 찾는 말 · 기본 품목 · 검증 칸 · 프로젝트에서 고름) · 조건 찾기(범위 + 「규격 / 이름에 [알약] 포함 / 같음」 · 맞음 0 · 여럿 알림) · 알약 이름 = 자리 이름. 다른 그룹은 상자를 늘림. +6. **다른 로직 입력** — 저장한 흐름도 로직(또는 견본)을 입력 상자로 불러 그 출력(비용 · 값)을 내보내는 알약으로 · 그 로직의 「설계 때 정할 것」(변수 · 재료 자리)은 이 상자의 받는 알약으로(이어 주지 않으면 그 로직의 예시값). +7. **선 규칙**(선 하나에 하나 · 없으면 그대로) — 표에서 찾기(조건 칸마다 맞출 알약 · 꺼낼 칸) · 조건 통과(알약 · 같음 · 다름 · 이상 · 이하 · 초과 · 미만 · 값 — 아니면 넘기지 않음) · 바꾸기(× · ÷ · +% · −% · 단위 바꾸기 계수) · 끝수(자리 · 반올림 / 올림 / 버림). 규칙은 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임 · 한 알약에서 나간 조건 선의 겹침 · 어느 것도 안 맞음을 알림. +8. **흐름 따라가기** — 알약 · 선을 고르면 그 값이 온 길(출처 사슬)과 가는 길을 다이어그램에서 칠하고 상세 내용에 줄로 · 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건 · 맞는 재료 없음)을 상자 · 선 위에 표시. +9. **상세 내용**(옛 「고른 상자」) — 상자 · 알약 · 선 무엇을 골라도 바뀜 · 머리 왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆에 알약 이름 · 칸 차례 = 머리 · 설정 · 본문 · 경고. +10. **단위는 고르기** — 치면 걸러진 목록 · 고르기는 사용자 · 손글 안 받음 · 목록 = 단위 표준 + 마스터 결과단위 + 원 · 「-」 = 없음. +11. **단위 표준 · 금액 단위 표준** — 산림품셈 1-2-2(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md`) 「설계서의 단위 및 소수의 표준」(종목 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③ · 소수자리 「-」 = 0 · 단위 「-」 = 없음) + 「금액의 단위표준」(총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 미만 버림) · 시험판 안에서 표로 보고 고침(사용자가 원문 흠을 고침 · 저장 `_data`). +12. **끝수** — 함수 출력 · 출력 상자 · 선 규칙에 소수자리 · 방법 · 단위를 고르면 단위 표준에서 자리 제안(주 ① 반올림 · ② 항목 값 우선) · 출력(비용) 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림. +13. **화면** — 자유 배치 다이어그램(위 · 크게) · 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자 선택 안 됨 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 아래 상세 내용 · 오른쪽 아래 검증 칸(예시값 + 재료 자리 + 다른 로직의 설계 때 정할 것) · 단추 · 칸은 공용 작은 크기(createButton size "sm" · 덮어쓰기 금지) · 쉬운 말 · 밝음 · 어두움. +14. 보고 · 검증 기준 = 흐름(값이 어디서 와서 · 어떤 규칙을 지나 · 어디로 가는지가 화면에서 따라가지는지 · 끊긴 곳이 보이는지 · 몇 걸음에 만드나). 원가 숫자는 내부 확인만. + +## 파일 · 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 0걸음: CostFlow → DataFlow 옮기기(이름 · 길 · 키 · 폴더 · 시험 사본) · 그다음 Model · Eval 넓히기(정함 5 · 6 · 7 · 8 · 12 · 설계 때 정할 것 목록) — 타입 먼저 | +| sub4 | 서버(정함 5 · 6 · 11 · 12) — 재료 자리 · 조건 찾기 단가 · 자재 찾기(쪽 · 띄어쓰기 AND · 규격) · 다른 로직 값 · 설계 때 정할 것 · 표준 표 읽기 · 쓰기 · 저장 다시 셈의 끝수 | +| sub8 | 다이어그램(정함 7 · 8 · 13) — 선 위 규칙 글 · 선 고르기 · 흐름 길 칠하기 · 끊긴 곳 표시 · 마우스 | +| sub2 | 상세 내용 판(정함 7 · 9 · 10 · 12 · 13) — 선 규칙 판 · 알약 판 · 함수 판 손질(작은 단추) · 단위 고르기 공용 파일 `M02_DF_UnitPick.ts` · 끝수 고르기 | +| sub3 | 입력 판(정함 5 · 6 · 10) — 재료 방식 셋 · 범위 · 조건 · 기본 품목 · 자재 찾기 고침 · 다른 로직 입력 | +| sub5 | 출력 판 · 검증 칸(정함 5 · 6 · 11 · 12) — 재료 자리 고르기 · 다른 로직 설계 값 · 출력 끝수 · 금액 표준 기본 · 끊긴 곳 목록 | +| sub6 | 표준 표 · 견본(정함 11 · 견본) — `_standard/단위표준.json` · `금액단위표준.json` · 시험판 안 표 보기 · 고치기 판 · 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) | +| sub1 | 옛 시험판 지우기(정함 3) · 막대 목록 · 들어가는 페이지 · 상세 내용 머리 · 잇기 · 끝 검증(흐름 기준) | From fc7affb0193da2bc75cbf93313b6419e7dc4d35b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:37:51 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(m02-df):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=9D=90=EB=A6=84=EB=8F=84=200=EA=B1=B8=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20CostFlow=20=EB=A5=BC=20DataFlow=20=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AE=EA=B2=A8=20=EC=9D=B4=EB=A6=84=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로 - _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함 - 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로) - 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/.gitignore | 1 + M02_MasterTemplete/DataFlow/M02_DF_Api.ts | 158 ++++ .../DataFlow/M02_DF_Diagram.css | 276 ++++++ M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 682 +++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Entry.css | 220 +++++ M02_MasterTemplete/DataFlow/M02_DF_Entry.ts | 579 +++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Eval.ts | 210 +++++ .../DataFlow/M02_DF_FuncPanel.css | 180 ++++ .../DataFlow/M02_DF_FuncPanel.ts | 700 ++++++++++++++++ .../DataFlow/M02_DF_InputPanel.css | 145 ++++ .../DataFlow/M02_DF_InputPanel.ts | 459 ++++++++++ .../DataFlow/M02_DF_InputPanel_Build.ts | 294 +++++++ .../DataFlow/M02_DF_InputPanel_Table.ts | 323 +++++++ .../DataFlow/M02_DF_InputPanel_Vars.ts | 201 +++++ M02_MasterTemplete/DataFlow/M02_DF_Model.ts | 329 ++++++++ .../DataFlow/M02_DF_OutputPanel.css | 85 ++ .../DataFlow/M02_DF_OutputPanel.ts | 195 +++++ M02_MasterTemplete/DataFlow/M02_DF_Router.py | 664 +++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Sample.ts | 65 ++ .../DataFlow/M02_DF_Summary.css | 132 +++ M02_MasterTemplete/DataFlow/M02_DF_Summary.ts | 279 +++++++ .../DataFlow/_sample/masonry.json | 785 ++++++++++++++++++ M02_MasterTemplete/DataFlow/_sample/rate.json | 146 ++++ .../DataFlow/_sample/value.json | 200 +++++ .../tester/test_m02_df_inputpanel_build.py | 254 ++++++ resources/tester/test_m02_df_model.py | 348 ++++++++ resources/tester/test_m02_df_router.py | 353 ++++++++ resources/tester/test_m02_df_sample.py | 275 ++++++ 28 files changed, 8538 insertions(+) create mode 100644 M02_MasterTemplete/DataFlow/.gitignore create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Api.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Diagram.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Entry.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Entry.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Eval.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Model.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Router.py create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Sample.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Summary.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Summary.ts create mode 100644 M02_MasterTemplete/DataFlow/_sample/masonry.json create mode 100644 M02_MasterTemplete/DataFlow/_sample/rate.json create mode 100644 M02_MasterTemplete/DataFlow/_sample/value.json create mode 100644 resources/tester/test_m02_df_inputpanel_build.py create mode 100644 resources/tester/test_m02_df_model.py create mode 100644 resources/tester/test_m02_df_router.py create mode 100644 resources/tester/test_m02_df_sample.py diff --git a/M02_MasterTemplete/DataFlow/.gitignore b/M02_MasterTemplete/DataFlow/.gitignore new file mode 100644 index 000000000..c10c784e3 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/.gitignore @@ -0,0 +1 @@ +_data/ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts new file mode 100644 index 000000000..83706b0fb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -0,0 +1,158 @@ +/* ============================================================================= + * M02_DF_Api.ts + * 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 받아 + * 함수 상자 시트를 같은 엔진으로 셈하고 받는 칸에 값만 되돌려 넣음) · 지우기 · 입력 값 한 번에 받기 · + * 사용자 요소. 모양 = sub7 Model(CfLogic · CfGroup · CfUserElement) · 아래는 Api 몫만. + * 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { CfGroup, CfLogic, CfUserElement } from "./M02_DF_Model"; + +/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */ +export type CfField = "노무비" | "재료비" | "경비" | "계"; + +/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `UE-…` 는 다이어그램 안 · `_data`)에서 + * · 인력 · 재료 ref = 키 → 그 줄 단가(기존 계산과 같은 셈) · 재료 ref = 검색어 묶음 JSON 글도 됨(unit 을 주면 + * 기존 로직 재료 줄과 같은 단가) + * · 유가전력 · 환율 = 그 줄 값 · 요율 = 값 묶음의 field(기본 rate_percent · %) + * · 기계 ref = EQ 키 + field(값 칸 — 가격 · 손료계수 …) 또는 `로직(GC…, 기계='…', 지역=지역)` + inputs → 한 시간 값 + * · 소요량 · 계수 table = {key, row?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」) */ +export interface CfQuery { + id: string; + group: CfGroup; + ref?: string; + table?: { key: string; row?: number }; + unit?: string; + field?: string; + inputs?: Record; +} + +/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ +export interface CfAnswer { + id: string; + ok: boolean; + value?: number | string | null; + rows?: (string | number | null)[][]; + name?: string; + unit?: string; + reason?: string; +} + +/** 서버 계산 결과 — 저장 답 */ +export interface CfComputed { + /** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */ + ports: Record>; + /** 출력 상자 id → 값 */ + outputs: Record; + cost: { + 노무비: number; + 재료비: number; + 경비: number; + 값들: Record; + 계: number; + }; + /** 쉬운 말 */ + errors: string[]; +} + +/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */ +export interface CfListItem { + key: string; + name: string; + resultUnit: string; + saved: string; + sample: boolean; +} + +/** 저장 답 — `logic` 은 받는 칸에 서버 값이 들어간 다이어그램 */ +export interface CfSaved { + key: string; + logic: CfLogic; + values: CfComputed; +} + +/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */ +export class CfError extends Error { + constructor( + public status: number, + public detail: unknown, + ) { + super(typeof detail === "string" ? detail : JSON.stringify(detail)); + } +} + +async function send(path: string, body?: unknown): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m02/df${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new CfError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText); + return data; +} + +/** 내 다이어그램(저장 차례) + 견본 */ +export async function cfList(): Promise { + return (await send<{ logics: CfListItem[] }>("/list")).logics; +} + +export async function cfGet(key: string): Promise { + return (await send<{ logic: CfLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic; +} + +/** 저장 — 서버가 입력 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) · + * 견본을 저장하면 새 번호 */ +export async function cfSave(logic: CfLogic): Promise { + return send("/save", { logic }); +} + +export async function cfDelete(key: string): Promise { + await send<{ key: string }>("/delete", { key }); +} + +/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 · + * userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */ +export async function cfValues( + items: CfQuery[], + userElements: CfUserElement[] = [], +): Promise { + return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values; +} + +/** 사용자 요소 목록 */ +export async function cfUserElements(): Promise { + return (await send<{ elements: CfUserElement[] }>("/user-elements")).elements; +} + +/** 사용자 요소 만들기(공용 목록 `_data`) — id(`UE-0001`)는 서버가 줌 · 마스터에 안 씀 */ +export async function cfUserElementAdd(element: Omit): Promise { + return (await send<{ element: CfUserElement }>("/user-elements", { op: "add", element })).element; +} + +export async function cfUserElementDelete(id: string): Promise { + await send<{ id: string }>("/user-elements", { op: "delete", id }); +} + +const PLAIN: Record = { + 0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", + 400: "넣은 값의 모양이 맞지 않음", + 403: "견본은 바꾸거나 지울 수 없음 — 저장하면 새 다이어그램으로 남음", + 404: "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요", + 503: "엑셀 계산을 서버에서 못 함 — 잠시 뒤 다시 해 주세요", +}; + +/** 실패 → 쉬운 말(키 · 날 글이 섞인 서버 글은 뭉뚱그림) */ +export function cfPlain(e: unknown): string { + if (!(e instanceof CfError)) return e instanceof Error ? e.message : PLAIN[0]; + const text = typeof e.detail === "string" ? e.detail : ""; + if (e.status === 400 && text && !/[A-Z]{2}\d{6}|[{}]/.test(text)) return text; + return PLAIN[e.status] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요"; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css new file mode 100644 index 000000000..540e18ed3 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css @@ -0,0 +1,276 @@ +/* 단가산출 다이어그램 — 자유 배치 */ +.m02-dfdg { + --cfdg-in: var(--color-accent); + --cfdg-out: #c2610c; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + font-size: var(--text-body-sm); +} + +:root[data-theme="dark"] .m02-dfdg { + --cfdg-out: #e5a866; + --cfdg-lift: #fff; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .m02-dfdg { + --cfdg-out: #e5a866; + --cfdg-lift: #fff; + } +} + +.m02-dfdg__bar, +.m02-dfdg__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dfdg__tools { + min-height: 1.75rem; +} + +.m02-dfdg__title { + font-size: var(--text-body); +} + +.m02-dfdg__gap { + flex: 1; +} + +.m02-dfdg__zoom { + min-width: 3rem; + text-align: center; + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); +} + +.m02-dfdg__note { + color: var(--color-danger); +} + +.m02-dfdg__hint { + margin: 0; + color: var(--color-text-muted); +} + +/* 판 — 화면 위쪽 큰 영역 */ +.m02-dfdg__view { + position: relative; + height: max(360px, 55vh); + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background-color: var(--color-canvas); + background-image: radial-gradient( + color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px, + transparent 1px + ); + background-size: 20px 20px; + cursor: grab; + touch-action: none; +} + +.m02-dfdg__world { + position: absolute; + inset: 0 auto auto 0; + width: 1px; + height: 1px; + transform-origin: 0 0; +} + +.m02-dfdg__lines { + position: absolute; + inset: 0 auto auto 0; + width: 1px; + height: 1px; + overflow: visible; + pointer-events: none; +} + +/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */ +.m02-dfdg__box { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 11rem; + max-width: 16rem; + padding: 0 0 var(--spacing-8); + border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border)); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: 0 1px 3px rgb(0 0 0 / 12%); + cursor: pointer; +} + +.m02-dfdg__box:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.m02-dfdg__box.is-selected { + border: 2px solid var(--cf-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent); +} + +.m02-dfdg__head { + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + gap: 0 var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface)); + cursor: move; +} + +.m02-dfdg__kind { + grid-column: 1; + /* 어두운 화면은 종류 색을 밝게 섞음 */ + color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color))); + font-size: var(--text-caption); + font-weight: 600; +} + +.m02-dfdg__unit { + grid-column: 2; + grid-row: 1; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-dfdg__name { + grid-column: 1 / -1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-dfdg__pills { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + padding: 0 var(--spacing-8); +} + +.m02-dfdg__ins, +.m02-dfdg__outs { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-dfdg__outs { + align-items: flex-end; +} + +/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */ +.m02-dfdg__pill { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); + max-width: 9rem; + padding: 0 var(--spacing-8); + border: 1px solid var(--cfdg-in); + border-radius: 999px; + background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface)); + color: var(--cfdg-in); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; + white-space: nowrap; +} + +.m02-dfdg__pill.is-out { + border-color: var(--cfdg-out); + background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface)); + color: var(--cfdg-out); + cursor: grab; + touch-action: none; +} + +.m02-dfdg__pill.is-empty { + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); +} + +.m02-dfdg__pill.is-chosen { + box-shadow: 0 0 0 2px currentColor; +} + +.m02-dfdg.is-linking .m02-dfdg__pill.is-target { + box-shadow: 0 0 0 2px var(--cfdg-in); +} + +.m02-dfdg__pill-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.m02-dfdg__pill-value { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.m02-dfdg__pill-value:empty { + display: none; +} + +/* 선 — 굽은 선 */ +.m02-dfdg__link { + pointer-events: auto; + cursor: pointer; +} + +.m02-dfdg__hit { + fill: none; + stroke: transparent; + stroke-width: 12; +} + +.m02-dfdg__wire { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; +} + +.m02-dfdg__link.is-strong .m02-dfdg__wire { + stroke: var(--color-accent); + stroke-width: 3; +} + +.m02-dfdg__link.is-dim .m02-dfdg__wire { + opacity: 0.25; +} + +.m02-dfdg__drag { + fill: none; + stroke: var(--cfdg-out); + stroke-width: 2; + stroke-dasharray: 5 4; +} + +/* 함수 상자 흐린 「+」 받는 자리 */ +.m02-dfdg__pill.is-plus { + justify-content: center; + min-width: 2rem; + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); + opacity: 0.7; +} + +.m02-dfdg__pill.is-plus:hover, +.m02-dfdg.is-linking .m02-dfdg__pill.is-plus.is-target { + opacity: 1; + border-color: var(--cfdg-in); + color: var(--cfdg-in); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts new file mode 100644 index 000000000..e00184f49 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -0,0 +1,682 @@ +/* ============================================================================= + * M02_DF_Diagram.ts + * 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」) + * 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 · + * 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) · + * 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게. + * SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { + portsIn, + portsOut, + type CfBox, + type CfKind, + type CfLogic, + type CfPill, + type CfPort, +} from "./M02_DF_Model"; +import "./M02_DF_Diagram.css"; + +const TEXT = { + Title: ["다이어그램", "Diagram"], + K_input: ["입력", "Input"], + K_function: ["함수", "Function"], + K_output: ["출력", "Output"], + AddInput: ["+ 입력", "+ Input"], + AddFunc: ["+ 함수", "+ Function"], + AddOutput: ["+ 출력", "+ Output"], + ZoomIn: ["확대", "Zoom in"], + ZoomOut: ["축소", "Zoom out"], + Fit: ["한눈에", "Fit all"], + Arrange: ["줄 맞추기", "Line up"], + Cancel: ["취소", "Cancel"], + Connect: ["잇기", "Connect"], + Disconnect: ["끊기", "Disconnect"], + Remove: ["상자 지우기", "Remove box"], + AskRemove: [ + "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", + "Remove box “{v}”? Its lines go too", + ], + PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"], + Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"], + NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"], + SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], + NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"], + NoMatch: [ + "같은 이름 받는 알약 없음 — 알약에 바로 놓으세요", + "No pill with that name — drop on a pill", + ], + Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"], + From: ["어디서", "From"], + To: ["어디로", "To"], + Value: ["지금 값", "Now"], + PlusIn: [ + "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", + "Drop an output pill here to add an input", + ], + LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], + Hint: [ + "상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대", + "Drag head to move · drag space to pan", + ], +} as const; + +const tt = (key: keyof typeof TEXT, fill: Record = {}): string => + TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); + +/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */ +export const KIND_COLOR: Record = { + input: "#2a6fb0", + function: "#7a4fb5", + output: "#c2610c", +}; + +export interface DiagramOn { + select(id: string | null): void; + move(boxId: string, x: number, y: number): void; + /** 잇기 — 막히면 까닭(쉬운 말) */ + link(from: CfPort, to: CfPort): string | null | void; + unlink(linkId: string): void; + add(kind: CfKind, x: number, y: number): void; + remove(boxId: string): void; +} + +export interface DiagramOpts { + /** 상자 id → 알약 이름 → 지금 값 */ + values?: Record>; + /** 고른 상자 id · 선 id */ + selected?: string | null; +} + +/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */ + +/** 알약 — Model 의 받는 · 내보내는 알약 */ +export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } { + return { ins: portsIn(box), outs: portsOut(box) }; +} + +/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ +export function resolveDrop( + lg: CfLogic, + from: CfPort, + target: { box: string; name: string | null; dir: "in" | "out" | null } | null, +): { to: CfPort } | { why: string } { + if (!target) return { why: tt("NotIn") }; + if (target.box === from.box) return { why: tt("SameBox") }; + if (target.dir === "out") return { why: tt("NotIn") }; + const box = lg.boxes.find((b) => b.id === target.box); + // 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」) + if (box?.kind === "function" && target.name === null) + return { to: { box: box.id, name: from.name } }; + const ins = box ? pillsOf(box).ins : []; + if (!box || !ins.length) return { why: tt("NoIn") }; + const name = + target.name ?? + ins.find((p) => p.name === from.name)?.name ?? + (ins.length === 1 ? ins[0].name : null); + if (!name) return { why: tt("NoMatch") }; + if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") }; + return { to: { box: box.id, name } }; +} + +/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */ +export function arrange( + lg: CfLogic, + gap = { x: 260, y: 130 }, +): Record { + const depth = new Map(lg.boxes.map((b) => [b.id, 0])); + for (let round = 0; round < lg.boxes.length; round++) { + let moved = false; + for (const l of lg.links) { + const d = (depth.get(l.from.box) ?? 0) + 1; + if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) { + depth.set(l.to.box, d); + moved = true; + } + } + if (!moved) break; + } + const rows = new Map(); + const out: Record = {}; + for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) { + const d = depth.get(b.id) ?? 0; + const r = rows.get(d) ?? 0; + rows.set(d, r + 1); + out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y }; + } + return out; +} + +/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */ +export const FIT_MIN = 0.9; + +/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */ +export function fitView( + rects: { x: number; y: number; w: number; h: number }[], + view: { w: number; h: number }, + pad = 40, +): { zoom: number; panX: number; panY: number } { + if (!rects.length) return { zoom: 1, panX: 0, panY: 0 }; + const x0 = Math.min(...rects.map((r) => r.x)) - pad; + const y0 = Math.min(...rects.map((r) => r.y)) - pad; + const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; + const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; + const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); + /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ + const place = (size: number, from: number, to: number): number => + Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; + return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) }; +} + +const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3))); + +/* ── 그리기 ─────────────────────────────────────────────────────── */ + +interface HostState { + selected: string | null; + linking: CfPort | null; + pill: { box: string; name: string; dir: "in" | "out" } | null; + note: string; + zoom: number; + panX: number; + panY: number; + fitted: boolean; + watch?: ResizeObserver; +} + +const states = new WeakMap(); +const SVG = "http://www.w3.org/2000/svg"; + +/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */ +export const showValue = (v: unknown): string => { + if (v === null || v === undefined || v === "") return ""; + if (typeof v === "number") + return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : ""; + if (Array.isArray(v)) { + const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0; + return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`; + } + return String(v).slice(0, 12); +}; + +export function renderDiagram( + host: HTMLElement, + lg: CfLogic, + on: DiagramOn, + opts: DiagramOpts = {}, +): void { + const st: HostState = states.get(host) ?? { + selected: null, + linking: null, + pill: null, + note: "", + zoom: 1, + panX: 0, + panY: 0, + fitted: false, + }; + if (opts.selected !== undefined) st.selected = opts.selected; + states.set(host, st); + const values = opts.values ?? {}; + const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? ""; + const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected }); + const select = (id: string | null): void => { + st.selected = id; + st.pill = null; + st.note = ""; + on.select(id); + redraw(); + }; + const tryLink = (from: CfPort, target: Parameters[2]): void => { + const got = resolveDrop(lg, from, target); + st.linking = null; + st.note = "why" in got ? got.why : on.link(from, got.to) || ""; + redraw(); + }; + + /* 판 · 세상(확대 · 옮김을 한 번에) */ + const world = el("div", { className: "m02-dfdg__world" }); + const svg = document.createElementNS(SVG, "svg"); + svg.classList.add("m02-dfdg__lines"); + world.append(svg); + const view = el("div", { + className: "m02-dfdg__view", + attrs: { "aria-label": tt("Title") }, + children: [world], + }); + const applyView = (): void => { + world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`; + zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`; + }; + /** 화면 점 → 세상 자리 */ + const toWorld = (cx: number, cy: number): { x: number; y: number } => { + const r = view.getBoundingClientRect(); + return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom }; + }; + + /* 상자 */ + const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => { + const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name); + const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir; + const btn = el("button", { + className: [ + "m02-dfdg__pill", + `is-${dir}`, + wired ? "" : "is-empty", + chosen ? "is-chosen" : "", + st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", + ].join(" "), + attrs: { + type: "button", + "data-box": b.id, + "data-name": p.name, + "data-dir": dir, + title: `${p.name} (${p.unit || "-"})`, + }, + children: [ + el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }), + el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }), + ], + }); + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir }); + st.pill = chosen ? null : { box: b.id, name: p.name, dir }; + st.linking = null; + st.note = ""; + redraw(); + }); + if (dir === "out") + btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name })); + return btn; + }; + + /** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */ + const plusSlot = (b: CfBox): HTMLElement => { + const slot = el("button", { + className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`, + text: "+", + attrs: { + type: "button", + "data-box": b.id, + "data-dir": "in", + "data-plus": "1", + title: tt("PlusIn"), + }, + }); + slot.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" }); + st.note = tt("PlusIn"); + redraw(); + }); + return slot; + }; + + const boxEl = (b: CfBox): HTMLElement => { + const { ins, outs } = pillsOf(b); + const head = el("div", { + className: "m02-dfdg__head", + children: [ + el("span", { className: "m02-dfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }), + el("strong", { className: "m02-dfdg__name", text: b.name }), + el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }), + ], + }); + const node = el("div", { + className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`, + attrs: { + "data-box": b.id, + tabindex: "0", + role: "button", + "aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`, + }, + children: [ + head, + el("div", { + className: "m02-dfdg__pills", + children: [ + el("div", { + className: "m02-dfdg__ins", + children: [ + ...ins.map((p) => pillEl(b, p, "in")), + ...(b.kind === "function" ? [plusSlot(b)] : []), + ], + }), + el("div", { + className: "m02-dfdg__outs", + children: outs.map((p) => pillEl(b, p, "out")), + }), + ], + }), + ], + }); + node.style.left = `${b.x}px`; + node.style.top = `${b.y}px`; + node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]); + node.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null }); + select(st.selected === b.id ? null : b.id); + }); + node.addEventListener("keydown", (ev) => { + if (ev.target !== node) return; + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + node.click(); + } + const step = ev.shiftKey ? 50 : 10; + const d = { + ArrowLeft: [-step, 0], + ArrowRight: [step, 0], + ArrowUp: [0, -step], + ArrowDown: [0, step], + }[ev.key]; + if (d && st.selected === b.id) { + ev.preventDefault(); + on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); + } + }); + head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node)); + return node; + }; + + world.append(...lg.boxes.map(boxEl)); + + /* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */ + const zoomLabel = el("span", { className: "m02-dfdg__zoom" }); + const setZoom = (z: number, cx?: number, cy?: number): void => { + const r = view.getBoundingClientRect(); + const px = cx ?? r.left + r.width / 2; + const py = cy ?? r.top + r.height / 2; + const at = toWorld(px, py); + st.zoom = clampZoom(z); + st.panX = px - r.left - at.x * st.zoom; + st.panY = py - r.top - at.y * st.zoom; + applyView(); + drawLines(); + }; + const fit = (): void => { + const rects = lg.boxes.map((b) => { + const n = world.querySelector(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`); + return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; + }); + const r = view.getBoundingClientRect(); + Object.assign(st, fitView(rects, { w: r.width, h: r.height })); + applyView(); + drawLines(); + }; + const freeSpot = (): { x: number; y: number } => { + const r = view.getBoundingClientRect(); + const c = toWorld(r.left + r.width / 3, r.top + r.height / 3); + let y = c.y; + while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100; + return { x: Math.round(c.x), y: Math.round(y) }; + }; + /** 작은 단추 */ + const sm = ( + label: keyof typeof TEXT, + onClick: () => void, + variant: "ghost" | "filled" | "danger" = "ghost", + ) => createButton({ label: tt(label), size: "sm", variant, onClick }); + const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement => + sm(label, () => { + const at = freeSpot(); + on.add(kind, at.x, at.y); + }); + const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`; + + const selLink = lg.links.find((l) => l.id === st.selected) ?? null; + const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null; + const askRemove = async (b: CfBox): Promise => { + if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id); + }; + const tools: HTMLElement[] = []; + if (st.linking) { + tools.push( + el("span", { text: tt("PickTarget", { v: st.linking.name }) }), + sm("Cancel", () => ((st.linking = null), redraw())), + ); + } else if (st.pill) { + const p = st.pill; + const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name; + const where = lg.links + .filter((l) => mine(p.dir === "in" ? l.to : l.from)) + .map((l) => endName(p.dir === "in" ? l.from : l.to)) + .join(", "); + const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`; + const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; + tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") })); + if (p.dir === "out") + tools.push( + sm( + "Connect", + () => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()), + "filled", + ), + ); + } else if (selLink) { + const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); + tools.push( + el("span", { text: line }), + sm("Disconnect", () => on.unlink(selLink.id), "danger"), + ); + } else if (selBox) { + tools.push(sm("Remove", () => void askRemove(selBox), "danger")); + } + + const bar = el("div", { + className: "m02-dfdg__bar", + children: [ + el("strong", { className: "m02-dfdg__title", text: tt("Title") }), + addBtn("input", "AddInput"), + addBtn("function", "AddFunc"), + addBtn("output", "AddOutput"), + el("span", { className: "m02-dfdg__gap" }), + sm("ZoomOut", () => setZoom(st.zoom / 1.2)), + zoomLabel, + sm("ZoomIn", () => setZoom(st.zoom * 1.2)), + sm("Fit", fit), + sm("Arrange", () => { + for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y); + }), + ], + }); + const root = el("div", { + className: `m02-dfdg${st.linking ? " is-linking" : ""}`, + children: [ + bar, + el("div", { + className: "m02-dfdg__tools", + children: [ + ...tools, + el("span", { className: "m02-dfdg__note", text: st.note }), + el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }), + ], + }), + lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }), + ], + }); + root.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && (st.linking || st.pill)) { + st.linking = null; + st.pill = null; + redraw(); + } + if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; + if (selLink) on.unlink(selLink.id); + else if (selBox) void askRemove(selBox); + }); + view.addEventListener("click", (ev) => { + if (ev.target === view || ev.target === world) select(null); + }); + view.addEventListener( + "wheel", + (ev) => { + ev.preventDefault(); + setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); + }, + { passive: false }, + ); + view.addEventListener("pointerdown", (ev) => { + if (ev.target !== view && ev.target !== world && ev.target !== svg) return; + dragPan(ev); + }); + host.replaceChildren(root); + applyView(); + + /* 선 — 알약 자리를 재서 세상 좌표로 */ + function drawLines(): void { + svg.replaceChildren(); + const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => { + const pill = world.querySelector( + `.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`, + ); + if (!pill) return null; + const r = pill.getBoundingClientRect(); + return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2); + }; + for (const l of lg.links) { + const a = at(l.from, "out"); + const b = at(l.to, "in"); + if (!a || !b) continue; + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; + const strong = + st.selected === l.id || + (selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id)); + const g = document.createElementNS(SVG, "g"); + g.setAttribute( + "class", + `m02-dfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`, + ); + g.setAttribute("data-link", l.id); + g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(",")); + for (const cls of ["m02-dfdg__hit", "m02-dfdg__wire"]) { + const p = document.createElementNS(SVG, "path"); + p.setAttribute("d", d); + p.setAttribute("class", cls); + g.append(p); + } + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + select(st.selected === l.id ? null : l.id); + }); + svg.append(g); + } + } + const firstFit = (): void => { + if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines(); + st.fitted = true; + fit(); + }; + st.watch?.disconnect(); + st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit); + st.watch?.observe(view); + if (view.isConnected) firstFit(); + + /* 끌기 셋 — 상자 · 판 · 선 */ + function follow( + ev: PointerEvent, + move: (m: PointerEvent) => void, + end: (u: PointerEvent, moved: boolean) => void, + ): void { + if (ev.button !== 0) return; + const x0 = ev.clientX; + const y0 = ev.clientY; + let moved = false; + const mv = (m: PointerEvent): void => { + if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; + moved = true; + move(m); + }; + const up = (u: PointerEvent): void => { + window.removeEventListener("pointermove", mv); + window.removeEventListener("pointerup", up); + if (moved) { + // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 + const swallow = (c: MouseEvent): void => c.stopPropagation(); + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); + } + end(u, moved); + }; + window.addEventListener("pointermove", mv); + window.addEventListener("pointerup", up); + } + + function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void { + const start = toWorld(ev.clientX, ev.clientY); + let x = b.x; + let y = b.y; + follow( + ev, + (m) => { + const now = toWorld(m.clientX, m.clientY); + x = Math.round(b.x + now.x - start.x); + y = Math.round(b.y + now.y - start.y); + node.style.left = `${x}px`; + node.style.top = `${y}px`; + drawLines(); + }, + (_u, moved) => { + if (moved) on.move(b.id, x, y); + }, + ); + } + + function dragPan(ev: PointerEvent): void { + const p0 = { x: st.panX, y: st.panY }; + follow( + ev, + (m) => { + st.panX = p0.x + m.clientX - ev.clientX; + st.panY = p0.y + m.clientY - ev.clientY; + applyView(); + }, + () => undefined, + ); + } + + function dragLink(ev: PointerEvent, from: CfPort): void { + ev.stopPropagation(); + const pill = ev.currentTarget as HTMLElement; + const r = pill.getBoundingClientRect(); + const a = toWorld(r.right, r.top + r.height / 2); + let line: SVGPathElement | null = null; + follow( + ev, + (m) => { + const b = toWorld(m.clientX, m.clientY); + if (!line) { + line = document.createElementNS(SVG, "path"); + line.setAttribute("class", "m02-dfdg__drag"); + svg.append(line); + } + line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`); + }, + (u, moved) => { + if (!moved) return; + line?.remove(); + const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; + const p = hit?.closest(".m02-dfdg__pill"); + const bx = hit?.closest(".m02-dfdg__box"); + tryLink( + from, + p + ? { + box: p.dataset.box ?? "", + name: p.dataset.name ?? null, + dir: (p.dataset.dir as "in" | "out") ?? null, + } + : bx + ? { box: bx.dataset.box ?? "", name: null, dir: null } + : null, + ); + }, + ); + } +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css new file mode 100644 index 000000000..3b453c20b --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css @@ -0,0 +1,220 @@ +/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */ + +.m02-df { + position: fixed; + inset: 0; + z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */ + overflow-x: hidden; + overflow-y: auto; + background: var(--color-canvas); + color: var(--color-text); + font-size: var(--text-body); +} + +.m02-df:focus { + outline: none; +} + +.m02-df__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +/* 위 띠 — 밀어도 위에 붙어 있음 */ +.m02-df__head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding: var(--spacing-12) var(--spacing-24); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.m02-df__title { + margin: 0; + font-size: var(--text-subheading); +} + +.m02-df__trial { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-df__strip { + display: flex; + flex: 1 1 480px; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); +} + +.m02-df__name { + flex: 1 1 260px; +} + +.m02-df__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +.m02-df__body { + padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); +} + +.m02-df__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +/* 시작 화면 */ +.m02-df__start { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--spacing-24); +} + +.m02-df__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m02-df__start-col h3 { + margin: var(--spacing-8) 0 0; +} + +/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */ +.m02-df__layout { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.m02-df__part { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m02-df__part h3 { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m02-df__part-body { + padding: var(--spacing-12); +} + +.m02-df__lower { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); + gap: var(--spacing-16); + align-items: start; +} + +.m02-df__box .m02-df__part-body { + min-height: 50vh; +} + +.m02-df__box-slots { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--spacing-12); +} + +.m02-df__slot { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m02-df__slot h4 { + margin: 0; +} + +@media (max-width: 1100px) { + .m02-df__lower, + .m02-df__start, + .m02-df__box-slots { + grid-template-columns: minmax(0, 1fr); + } +} + +.m02-df__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-df__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-df__item:hover { + border-color: var(--color-accent); +} + +.m02-df__item-name { + flex: 1; + min-width: 0; +} + +.m02-df__item-unit { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +.m02-df__note { + margin: 0 0 var(--spacing-12); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m02-df__note:empty { + display: none; +} + +.m02-df__color { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-bottom: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-df__color[hidden] { + display: none; +} + +.m02-df__color-input { + width: 44px; + height: 32px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: none; + cursor: pointer; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts new file mode 100644 index 000000000..7fa2de71a --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -0,0 +1,579 @@ +/* ============================================================================= + * M02_DF_Entry.ts + * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 + * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 + * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게. + * 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary + * → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S. + * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { CfListItem, CfQuery } from "./M02_DF_Api"; +import type { CfEvalResult, CfValues } from "./M02_DF_Eval"; +import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model"; +import "./M02_DF_Entry.css"; + +/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ +const loadMods = () => + Promise.all([ + import("./M02_DF_Model"), + import("./M02_DF_Eval"), + import("./M02_DF_Api"), + import("./M02_DF_Diagram"), + import("./M02_DF_FuncPanel"), + import("./M02_DF_OutputPanel"), + import("./M02_DF_Summary"), + import("./M02_DF_Sample"), + import("./M02_DF_InputPanel"), + ]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({ + model, + ev, + api, + diagram, + func, + output, + summary, + sample, + input, + })); +type Mods = Awaited>; + +const TEXT = { + Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], + Title: ["단가산출 다이어그램", "Cost diagram"], + Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], + Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], + Start_New: ["새로 만들기", "New"], + Start_Mine: ["내 로직", "My logics"], + Start_MineNone: ["아직 없음", "None yet"], + Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], + Name: ["로직 이름", "Logic name"], + NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], + Unit: ["결과 단위", "Result unit"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Unsaved: ["저장 안 함", "Not saved"], + Diagram: ["다이어그램", "Diagram"], + Box: ["고른 상자", "Chosen box"], + PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], + Summary: ["원가", "Cost"], + Color: ["상자 색", "Box colour"], + NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], + SaveAnyway: [ + "고칠 곳이 있음 — {v} · 그래도 저장할까요?", + "There are issues — {v} · Save anyway?", + ], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], + Differs: [ + "저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름", + "Recalculated on save: {s} — differs from the screen value {c}", + ], + Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["지웠음", "Deleted"], + Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], + Leave_Ask: [ + "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", + "There are unsaved changes — close without saving?", + ], + Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], +} as const; + +type TextKey = keyof typeof TEXT; + +const te = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text }); + +/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ +async function unitChoices(): Promise { + const count = new Map(); + try { + for (const x of await fetchLogics()) { + if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); + } + } catch { + /* 못 받으면 기본 하나 */ + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; +} + +/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ +function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { + return lg.boxes.flatMap((b) => { + const inp = b.input; + if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; + const group = inp.group; + const els = inp.elements + .filter((e) => e.ref && !e.ref.startsWith("UE-")) + .map((e) => { + // 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김 + const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined]; + const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit }; + if (field) q.field = field; + return { q, box: b.id, port: e.name }; + }); + const table = inp.table + ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] + : []; + return [...els, ...table]; + }); +} + +/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ +async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> { + const asks = queriesOf(lg); + if (!asks.length) return { values: {}, errors: [] }; + const answers = await M.api.cfValues( + asks.map((a) => a.q), + lg.userElements as never, + ); + const values: CfValues = {}; + const errors: string[] = []; + for (const a of asks) { + const got = answers.find((x) => x.id === a.q.id); + if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); + else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; + } + return { values, errors }; +} + +/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ +export function createDataFlowButton(): HTMLButtonElement { + return createButton({ label: te("Open"), variant: "filled", onClick: openDataFlow }); +} + +function openDataFlow(): void { + /** 고치는 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; + let units: string[] | null = null; + const mods = loadMods(); + + const mayLeave = async (): Promise => + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + + const body = el("main", { className: "m02-df__body" }); + const strip = el("div", { className: "m02-df__strip" }); + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) paper ? void showStart() : shut(); + })(), + }); + const page = el("div", { + className: "m02-df", + attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, + children: [ + el("header", { + className: "m02-df__head", + children: [ + el("h2", { className: "m02-df__title", text: te("Title") }), + el("span", { className: "m02-df__trial", text: te("Trial") }), + strip, + close, + ], + }), + body, + ], + }); + const prevOverflow = document.body.style.overflow; + const shut = (): void => { + page.remove(); + document.body.style.overflow = prevOverflow; + }; + page.addEventListener("keydown", (ev) => { + if (ev.defaultPrevented) return; + if (ev.key === "Escape") { + void (async () => { + if (await mayLeave()) shut(); + })(); + return; + } + if (!paper || !(ev.ctrlKey || ev.metaKey)) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } + }); + + const run = (job: (M: Mods) => Promise): void => { + body.replaceChildren(muted(te("Loading"))); + void mods.then(job).catch((e: unknown) => { + showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); + void showStart(); + }); + }; + + /* ---------- 시작 화면 ---------- */ + + const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { + const row = el("button", { + className: "m02-df__item", + attrs: { type: "button", title }, + children: [ + el("span", { className: "m02-df__item-name", text: name }), + el("span", { className: "m02-df__item-unit", text: unit }), + ], + }); + row.addEventListener("click", onClick); + return row; + }; + + const showStart = async (): Promise => { + paper = null; + strip.replaceChildren(); + const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); + const sampleHost = el("div", { className: "m02-df__list" }); + body.replaceChildren( + el("div", { + className: "m02-df__start", + children: [ + el("section", { + className: "m02-df__start-col", + children: [ + bigButton(te("Start_New"), () => + run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), + ), + el("h3", { text: te("Start_Mine") }), + mineHost, + ], + }), + el("section", { + className: "m02-df__start-col", + children: [el("h3", { text: te("Start_Samples") }), sampleHost], + }), + ], + }), + ); + const M = await mods; + sampleHost.replaceChildren( + ...M.sample.sampleList().map((s) => + listRow( + s.name, + s.resultUnit, + () => + run(async (M2) => { + const lg = M2.sample.sampleLoad(s.id); + if (lg) await showPaper(M2, lg, false); + }), + s.note, + ), + ), + ); + try { + const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); + mineHost.replaceChildren( + ...(mine.length + ? mine.map((x) => + listRow( + x.name, + x.resultUnit, + () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), + x.saved, + ), + ) + : [muted(te("Start_MineNone"))]), + ); + } catch (e) { + mineHost.replaceChildren(muted(M.api.cfPlain(e))); + } + }; + const units_ = async (): Promise => (units ??= await unitChoices()); + + /* ---------- 고치는 화면 ---------- */ + + const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { + const choices = await units_(); + let lg = first; + let savedLg: CfLogic | null = saved ? first : null; + let stack: CfLogic[] = []; + let selected: string | null = null; + let values: CfValues = {}; + let valueSig = ""; + let notes: string[] = []; + let result: CfEvalResult; + let busy = false; + + const unsaved = createTag(te("Unsaved"), "warning"); + const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); + const diagramHost = el("div"); + const pickHint = muted(te("PickBox")); + // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 + const colorBox = el("input", { + className: "m02-df__color-input", + attrs: { type: "color", "aria-label": te("Color") }, + }); + colorBox.addEventListener("change", () => { + if (selected) edit(M.model.setColor(lg, selected, colorBox.value)); + }); + const colorRow = el("label", { + className: "m02-df__color", + children: [el("span", { text: te("Color") }), colorBox], + }); + // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) + const funcHost = el("div"); + const outHost = el("div"); + const inHost = el("div"); + const summaryHost = el("div"); + + const evalNow = (): CfEvalResult => { + try { + return M.ev.evalLogic(lg, values); + } catch (e) { + const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; + } + }; + + const edit = (next: CfLogic): void => { + if (next === lg) return; + stack = [...stack, lg]; + lg = next; + redraw(); + }; + /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */ + const editFromDiagram = (next: CfLogic): void => { + edit(next); + setTimeout(redraw); + }; + + /** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ + const refreshValues = (): void => { + const sig = JSON.stringify(queriesOf(lg).map((a) => a.q)); + if (sig === valueSig) return; + valueSig = sig; + void fetchValues(M, lg).then( + (got) => { + if (sig !== valueSig) return; + values = got.values; + notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; + redraw(); + }, + (e: unknown) => { + notes = [te("Values", { v: M.api.cfPlain(e) })]; + redraw(); + }, + ); + }; + + const name = createInputField({ + label: te("Name"), + placeholder: te("NamePlaceholder"), + value: lg.name, + onInput: (v) => edit({ ...lg, name: v }), + }); + name.root.classList.add("m02-df__name"); + const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; + const unit = createSelectField({ + label: te("Unit"), + options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: lg.resultUnit, + onChange: (v) => edit({ ...lg, resultUnit: v }), + }); + + const diagramOn = { + select: (id: string | null): void => { + selected = id; + drawPanel(); + }, + move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), + link: (from: CfPort, to: CfPort): string | null => { + const got = M.model.link(lg, from, to); + if (got.why) return got.why; + editFromDiagram(got.lg); + return null; + }, + unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), + add: (kind: CfKind, x: number, y: number): void => { + const got = M.model.addBox(lg, kind, x, y); + selected = got.box.id; + editFromDiagram(got.lg); + }, + remove: (id: string): void => { + if (selected === id) selected = null; + editFromDiagram(M.model.removeBox(lg, id)); + }, + }; + + const drawPanel = (): void => { + const box = lg.boxes.find((b) => b.id === selected); + pickHint.hidden = !!box; + colorRow.hidden = !box; + if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; + funcHost.hidden = box?.kind !== "function"; + outHost.hidden = box?.kind !== "output"; + inHost.hidden = box?.kind !== "input"; + if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); + if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "output") + M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); + }; + + const redraw = (): void => { + result = evalNow(); + refreshValues(); + unsaved.hidden = lg === savedLg; + undo.disabled = !stack.length; + deleteButton.hidden = !lg.key; + M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); + drawPanel(); + // 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임 + // ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠) + (M.summary.renderSummary as (...args: unknown[]) => void)( + summaryHost, + lg, + result, + { change: edit }, + { values }, + ); + note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n"); + }; + + const save = async (): Promise => { + if (busy) return; + if (!lg.name.trim()) { + note.textContent = te("NoName"); + return void name.input.focus(); + } + let problems: string[] = []; + try { + problems = M.ev.checkLogic(lg, values); + } catch { + problems = []; + } + const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); + if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; + busy = true; + note.textContent = te("Saving"); + const screen = result.cost.계; + try { + const got = await M.api.cfSave(lg); + lg = got.logic; + savedLg = lg; + stack = []; + redraw(); + const server = got.values.cost.계; + if (Math.abs(server - screen) > 0.5) + notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; + note.textContent = [...notes, ...result.errors].join("\n"); + showToast(te("Saved"), "success"); + } catch (e) { + note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); + } finally { + busy = false; + } + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + lg = back; + name.input.value = lg.name; + unit.select.value = lg.resultUnit; + redraw(); + }; + + const doDelete = async (): Promise => { + if (!lg.key || busy) return; + if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; + try { + await M.api.cfDelete(lg.key); + savedLg = lg; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (e) { + note.textContent = M.api.cfPlain(e); + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const deleteButton = createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), + }); + strip.replaceChildren( + name.root, + unit.root, + unsaved, + el("div", { + className: "m02-df__actions", + children: [ + undo, + createButton({ label: te("Save"), onClick: () => void save() }), + deleteButton, + ], + }), + ); + body.replaceChildren( + note, + el("div", { + className: "m02-df__layout", + children: [ + part(te("Diagram"), "m02-df__diagram", [diagramHost]), + el("div", { + className: "m02-df__lower", + children: [ + part(te("Box"), "m02-df__box", [pickHint, colorRow, inHost, funcHost, outHost]), + part(te("Summary"), "m02-df__summary", [summaryHost]), + ], + }), + ], + }), + ); + paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; + redraw(); + if (!lg.name) name.input.focus(); + }; + + void showStart(); + document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 + document.body.append(page); + page.tabIndex = -1; + page.focus(); +} + +/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ +function part(title: string, className: string, children: HTMLElement[]): HTMLElement { + return el("section", { + className: `m02-df__part ${className}`, + children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })], + }); +} + +function bigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m02-df__big"); + return button; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts new file mode 100644 index 000000000..b955c2d61 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -0,0 +1,210 @@ +/* ============================================================================= + * M02_DF_Eval.ts + * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 + * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 + * 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_DF_Model"; + +/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ +export type CfValue = number | string | boolean | null | (number | string | null)[][]; + +/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ +export type CfValues = Record>; + +type Scalar = number | string | boolean | null; + +export interface CfEvalResult { + /** 상자 id → 내보내는 알약 이름 → 값 */ + ports: Record>; + /** 출력 상자 id → 값 */ + outputs: Record; + cost: { + 노무비: number; + 재료비: number; + 경비: number; + /** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */ + 값들: Record; + 계: number; + }; + /** 쉬운 말 */ + errors: string[]; +} + +const TEXT = { + /** {box} {cell} {err} */ + CellError: [ + "「{box}」 {cell} 칸 계산이 막힘 — {err}", + "「{box}」 cell {cell} can't be worked out — {err}", + ], + Cycle: ["돌고 도는 참조", "circular reference"], + /** {box} */ + Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"], + NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"], + /** {box} {pill} */ + EmptyIn: [ + "「{box}」 의 받는 알약 「{pill}」 에 선이 없음", + "Pill 「{pill}」 of 「{box}」 has no line", + ], + NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], + NoOutput: ["출력 상자가 없음", "There is no output box"], + NoInCell: [ + "「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음", + "Pill 「{pill}」 of 「{box}」 is not placed in a cell", + ], + NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], +} as const; + +const tl = (key: keyof typeof TEXT, fill: Record = {}): string => + Object.entries(fill).reduce( + (s, [k, v]) => s.replace(`{${k}}`, v), + TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, + ); + +/* ── 차례 ───────────────────────────────────────────────────────────────── */ + +/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */ +function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } { + const funcs = lg.boxes.filter((b) => b.kind === "function"); + const done: CfBox[] = []; + const left = new Set(funcs.map((f) => f.id)); + for (let moved = true; moved && left.size;) { + moved = false; + for (const f of funcs) { + if (!left.has(f.id)) continue; + const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box)); + if (waits) continue; + done.push(f); + left.delete(f.id); + moved = true; + } + } + return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) }; +} + +/* ── 칸 ─────────────────────────────────────────────────────────────────── */ + +function putValue(칸: Record, cell: string, value: Scalar): void { + const style = 칸[cell]?.서식; + if (value === null) { + if (style) 칸[cell] = { 서식: style }; + else delete 칸[cell]; + } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; +} + +/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */ +function put(칸: Record, cell: string, value: CfValue): void { + const at = parseA1(cell); + if (!at) return; + if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value); + value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x))); +} + +const fromCalc = (v: CalcValue | undefined): Scalar => { + if (!v) return null; + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +}; + +/* ── 계산 ───────────────────────────────────────────────────────────────── */ + +/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ +function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record { + const given = values[box.id] ?? {}; + const pills = portsOut(box); + const out: Record = {}; + const inp = box.input; + if (!inp) return out; + // portsOut 차례 = 요소 · 표 · 변수 + const sources: CfValue[] = [ + ...(inp.mode === "group" + ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) + : []), + ...(inp.mode === "group" && inp.table ? [null] : []), + ...inp.variables.map((v) => v.example), + ]; + pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null))); + return out; +} + +/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */ +function runFunc( + box: CfBox, + incoming: Record, + given: Record, + errors: string[], +): Scalar { + const func = box.func!; + const sheet: Sheet = structuredClone(func.sheet); + for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null); + for (const v of func.variables) + if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); + const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); + for (const [cell, v] of Object.entries(values[sheet.id] ?? {})) + if ("오류" in v) { + const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; + errors.push(tl("CellError", { box: box.name, cell, err })); + } + const at = func.output.cell.toUpperCase(); + const c = sheet.칸[at]; + if (!c) return null; + return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); +} + +/** 화면 계산 */ +export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult { + const errors: string[] = []; + const ports: CfEvalResult["ports"] = {}; + for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values); + const into = (box: string): Record => + Object.fromEntries( + lg.links + .filter((l) => l.to.box === box) + .map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]), + ); + const { funcs, loop } = order(lg); + for (const f of loop) errors.push(tl("Loop", { box: f.name })); + for (const f of funcs) + ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) }; + const outputs: CfEvalResult["outputs"] = {}; + const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + for (const b of lg.boxes.filter((x) => x.kind === "output")) { + const v = into(b.id)[OUTPUT_PORT] ?? null; + const scalar: Scalar = Array.isArray(v) ? null : v; + outputs[b.id] = scalar; + const kind = b.output?.cost ?? "값"; + if (kind === "값") cost.값들[b.name] = scalar; + else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; + else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); + } + cost.계 = cost.노무비 + cost.재료비 + cost.경비; + return { ports, outputs, cost, errors }; +} + +/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ +export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { + const out: string[] = []; + for (const b of lg.boxes) { + for (const p of portsIn(b)) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) + out.push(tl("EmptyIn", { box: b.name, pill: p.name })); + if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) + out.push(tl("NoCell", { box: b.name })); + for (const i of b.func?.inputs ?? []) + if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); + } + if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); + const ev = evalLogic(lg, values); + for (const b of lg.boxes.filter((x) => x.kind === "input")) + for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {})) + if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill)) + out.push(tl("NoValue", { box: b.name, pill })); + return [...out, ...ev.errors]; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css new file mode 100644 index 000000000..80fb0f252 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -0,0 +1,180 @@ +.m02-dfpanel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m02-dfpanel-head, +.m02-dfpanel-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dfpanel-head { + justify-content: space-between; +} + +.m02-dfpanel-title { + font-size: var(--text-subheading); +} + +.m02-dfpanel-unitbox { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-left: auto; +} + +.m02-dfpanel-unit { + width: 6em; + text-align: center; +} + +.m02-dfpanel-cell { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m02-dfpanel-label { + font-weight: var(--font-weight-bold); +} + +.m02-dfpanel-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-dfpanel .ui-btn, +.m02-dfpanel .ui-input, +.m02-dfpanel .ui-select { + min-height: 44px; + font-size: var(--text-body); +} + +.m02-dfpanel-form { + align-items: flex-end; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m02-dfpanel-fieldbox { + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +.m02-dfpanel-field { + width: 9em; +} + +.m02-dfpanel-field.is-num { + width: 6em; +} + +.m02-dfpanel-why, +.m02-dfpanel-msg { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m02-dfpanel-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-height: 44px; + padding: 0 var(--spacing-12); + border: 2px solid; + border-radius: var(--radius-pills); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); +} + +button.m02-dfpanel-chip { + cursor: pointer; +} + +button.m02-dfpanel-chip:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m02-dfpanel-chip.is-in { + border-color: #3b82f6; +} + +.m02-dfpanel-chip.is-in:not(.is-placed) { + border-style: dashed; +} + +.m02-dfpanel-chip.is-out { + border-color: #f59e0b; +} + +.m02-dfpanel-chip.is-var { + border-color: #22a06b; +} + +/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ +.m02-dfpanel-sheet { + position: relative; + height: max(640px, calc(100vh - 260px)); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; +} + +/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */ +.m02-dfpanel .aislo-cell[data-cf-kind="in"] { + box-shadow: inset 0 0 0 2px #3b82f6; +} + +.m02-dfpanel .aislo-cell[data-cf-kind="var"] { + box-shadow: inset 0 0 0 2px #22a06b; +} + +.m02-dfpanel .aislo-cell[data-cf-kind="out"] { + box-shadow: inset 0 0 0 2px #f59e0b; +} + +.m02-dfpanel .aislo-cell[data-cf-kind]::after { + content: attr(data-cf-name); + position: absolute; + top: 0; + right: 0; + padding: 0 4px; + border-radius: 0 0 0 6px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m02-dfpanel .aislo-cell[data-cf-lock]::before { + content: attr(data-cf-lock); + position: absolute; + bottom: 0; + left: 0; + padding: 0 4px; + background: var(--color-surface); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m02-dfpanel .aislo-cell[data-cf-err] { + outline: 2px dashed var(--color-danger); + outline-offset: -2px; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts new file mode 100644 index 000000000..a38ce4aba --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -0,0 +1,700 @@ +/* ============================================================================= + * M02_DF_FuncPanel.ts + * 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) · + * 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류 + * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로 + * 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { + CalcValues, + SpreadsheetHandle, + Workbook, +} from "../../A00_Common/spreadsheet/spreadsheet"; +import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model"; +import "./M02_DF_FuncPanel.css"; + +const TEXT = { + Unit: ["단위", "Unit"], + Cell: ["고른 칸", "Selected cell"], + Got: ["받은 알약", "Received pills"], + GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], + NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], + Placed: ["놓임", "placed"], + Free: ["아직 안 놓은 알약", "not placed yet"], + Busy: ["이미 쓰는 칸", "That cell is already in use"], + Locked: [ + "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", + "This cell is filled by the wire — cannot edit", + ], + Vars: ["변수", "Variables"], + MakeVar: ["변수 만들기", "Make variable"], + NoVars: ["아직 변수가 없음", "No variables yet"], + VarName: ["이름", "Name"], + VarType: ["갈래", "Type"], + Number: ["숫자", "Number"], + Choice: ["선택", "Choice"], + Text: ["글", "Text"], + VarUnit: ["단위", "Unit"], + VarMin: ["최소", "Min"], + VarMax: ["최대", "Max"], + VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], + VarExample: ["예시값", "Example value"], + VarCell: ["붙을 칸", "Attach to"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Remove: ["지움", "Remove"], + NeedName: ["이름을 적음", "Enter a name"], + SameName: ["같은 이름이 이미 있음", "That name already exists"], + NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], + BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], + OutBtn: ["출력 칸으로", "Make output cell"], + OutNow: ["출력", "Output"], + OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], + OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], + Frames: ["틀", "Templates"], + FrameMul: ["곱하기", "Multiply"], + FrameMore: ["할증", "Markup"], + FrameDiv: ["나누기", "Divide"], + FrameIf: ["만약", "If"], + FrameMin: ["최소", "Min"], + FrameMax: ["최대", "Max"], + FrameUp: ["올림", "Round up"], + FrameLook: ["표에서 찾기", "Look up in table"], + FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], + ReadOnly: ["읽기만", "Read only"], + Loading: ["시트를 불러오는 중", "Loading sheet"], + Problem: ["⚠ ", "⚠ "], + Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], + Err_DIV0: ["0으로 나눔", "divides by zero"], + Err_NAME: ["알 수 없는 이름", "unknown name"], + Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], + Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], + Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], + Err_NA: ["찾는 값이 없음", "value not found"], + Err_OTHER: ["수식이 틀림", "formula is wrong"], +} as const; + +const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ +const FRAMES: [keyof typeof TEXT, string][] = [ + ["FrameMul", "=B1*B2"], + ["FrameMore", "=B1*(1+B2/100)"], + ["FrameDiv", "=B1/B2"], + ["FrameIf", '=IF(B3="급",10,0)'], + ["FrameMin", "=MIN(B1,B2)"], + ["FrameMax", "=MAX(B1,B2)"], + ["FrameUp", "=ROUNDUP(B4,3)"], + ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], +]; + +const ERR_KEY: Record = { + "#DIV/0!": "Err_DIV0", + "#NAME?": "Err_NAME", + "#REF!": "Err_REF", + "#VALUE!": "Err_VALUE", + "#CYCLE!": "Err_CYCLE", + "#N/A": "Err_NA", +}; + +type Handlers = { change(lg: CfLogic): void }; + +interface VarForm { + name: string; + type: CfVariable["type"]; + unit: string; + min: string; + max: string; + choices: string; + example: string; + why: string; +} + +interface State { + boxId: string; + lg: CfLogic; + on: Handlers; + readOnly: boolean; + handle: SpreadsheetHandle | null; + /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ + snap: CalcValues; + /** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */ + json: string; + cell: string; + form: VarForm | null; + parts: { + head: HTMLElement; + got: HTMLElement; + vars: HTMLElement; + bar: HTMLElement; + msg: HTMLElement; + sheet: HTMLElement; + }; + observer: MutationObserver | null; + redraw(): void; +} + +const states = new WeakMap(); +let sheetModule: Promise | null = null; +/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ +const loadSheet = (): Promise => + (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); + +const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId); +const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func; +const bookOf = (func: CfFunc): Workbook => ({ + 종류: "통합문서", + 판: 1, + 열: "cf", + 서식: [{}], + 시트: [structuredClone(func.sheet)], + 활성: func.sheet.id, +}); +const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet); + +/* ------------------------------ 바꾸기 ------------------------------ */ + +function patchBox(st: State, make: (b: CfBox) => CfBox): void { + const next: CfLogic = { + ...st.lg, + boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)), + }; + st.lg = next; + st.on.change(next); +} + +const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void => + patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b)); + +const usedCells = (f: CfFunc): Map => { + const m = new Map(); + for (const i of f.inputs) m.set(i.cell, i.port); + for (const v of f.variables) if (v.cell) m.set(v.cell, v.name); + if (f.output.cell) m.set(f.output.cell, f.output.name); + return m; +}; + +function placePort(st: State, port: string, cell: string): void { + const f = funcOf(st); + if (!f || st.readOnly) return; + const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell); + if (other) return void showToast(tf("Busy"), "error"); + patchFunc(st, (x) => ({ + ...x, + inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }], + })); +} + +function makeVariable(st: State): void { + const form = st.form; + const f = funcOf(st); + if (!form || !f) return; + const name = form.name.trim(); + const fail = (key: keyof typeof TEXT): void => { + form.why = tf(key); + drawVars(st); + }; + if (!name) return fail("NeedName"); + const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)]; + const mine = f.variables.find((v) => v.name === name); + if (taken.includes(name) && !mine) return fail("SameName"); + const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) }); + if (busy.has(st.cell)) return fail("Busy"); + let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); + const v: CfVariable = { + name, + type: form.type, + unit: form.unit.trim() || "-", + example: null, + cell: st.cell, + }; + if (form.type === "number") { + if (example !== null) { + const n = Number(String(example).replace(/,/g, "")); + if (!Number.isFinite(n)) return fail("NotNumber"); + example = n; + } + const lo = form.min.trim() === "" ? null : Number(form.min); + const hi = form.max.trim() === "" ? null : Number(form.max); + if (lo !== null && hi !== null) { + if (!(lo < hi)) return fail("BadRange"); + v.range = [lo, hi]; + } + } else if (form.type === "choice") { + v.choices = form.choices + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + } + v.example = example; + const cell = st.cell; + st.form = null; + patchFunc(st, (x) => { + const sheet = structuredClone(x.sheet); + if (example !== null) sheet.칸[cell] = { 값: example }; + return { + ...x, + sheet, + inputs: x.inputs.filter((i) => i.cell !== cell), + variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v], + }; + }); +} + +/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */ +function incoming(st: State): { port: string; unit: string }[] { + const out = new Map(); + for (const l of st.lg.links) { + if (l.to.box !== st.boxId || out.has(l.to.name)) continue; + const src = st.lg.boxes.find((b) => b.id === l.from.box); + const unit = + src?.func && src.func.output.name === l.from.name + ? src.func.output.unit + : (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ?? + src?.input?.variables.find((v) => v.name === l.from.name)?.unit ?? + src?.unit ?? + "-"); + out.set(l.to.name, unit || "-"); + } + // 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리) + for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-"); + return [...out].map(([port, unit]) => ({ port, unit })); +} + +function problems(st: State): string[] { + const f = funcOf(st); + if (!f) return []; + const out: string[] = []; + for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) { + if (typeof v === "object" && "오류" in v) { + out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`); + } + } + for (const p of incoming(st)) { + if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`); + } + if (!f.output.cell) out.push(tf("OutNone")); + return out; +} + +/* ------------------------------ 칸 위 표시 ------------------------------ */ + +function applyMarks(st: State): void { + const f = funcOf(st); + if (!f) return; + const marks = new Map(); + for (const i of f.inputs.filter((x) => x.cell)) + marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); + for (const v of f.variables) + if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); + if (f.output.cell) + marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); + const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell)); + const list = problems(st); + const bad = new Set(list.map((p) => p.split(" ")[0])); + for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { + const addr = c.dataset.addr ?? ""; + for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a]; + const m = marks.get(addr); + if (m) { + c.dataset.cfKind = m.kind; + c.dataset.cfName = m.label; + } + if (locked.has(addr)) c.dataset.cfLock = "🔒"; + if (bad.has(addr)) c.dataset.cfErr = "1"; + } + st.parts.msg.textContent = list.length + ? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}` + : ""; + st.parts.msg.hidden = !list.length; +} + +/* ------------------------------ 위 줄들 ------------------------------ */ + +function drawHead(st: State): void { + const box = boxOf(st); + const unit = el("input", { + className: "ui-input m02-dfpanel-unit", + attrs: { type: "text", "aria-label": tf("Unit") }, + }); + unit.value = box?.unit ?? "-"; + if (st.readOnly) unit.readOnly = true; + unit.addEventListener("change", () => { + const v = unit.value.trim() || "-"; + unit.value = v; + patchBox(st, (b) => ({ + ...b, + unit: v, + func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, + })); + }); + st.parts.head.replaceChildren( + el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }), + el("span", { className: "m02-dfpanel-cell", text: `${tf("Cell")} ${st.cell}` }), + el("label", { + className: "m02-dfpanel-unitbox", + children: [el("span", { className: "m02-dfpanel-note", text: tf("Unit") }), unit], + }), + ); +} + +function drawGot(st: State): void { + const f = funcOf(st); + const items = incoming(st); + const chips = items.map(({ port, unit }) => { + const at = f?.inputs.find((i) => i.port === port)?.cell; + const b = el("button", { + className: `m02-dfpanel-chip is-in${at ? " is-placed" : ""}`, + text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`, + attrs: { type: "button", draggable: st.readOnly ? "false" : "true" }, + }); + b.addEventListener("click", () => placePort(st, port, st.cell)); + b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port)); + return b; + }); + st.parts.got.replaceChildren( + el("span", { className: "m02-dfpanel-label", text: tf("Got") }), + ...(chips.length ? chips : [el("span", { className: "m02-dfpanel-note", text: tf("NoGot") })]), + ...(chips.length && !st.readOnly + ? [el("span", { className: "m02-dfpanel-note", text: tf("GotHint") })] + : []), + ); +} + +function formField( + st: State, + label: keyof typeof TEXT, + key: "name" | "unit" | "min" | "max" | "choices" | "example", + cls = "", +): HTMLElement { + const form = st.form!; + const input = el("input", { + className: `ui-input m02-dfpanel-field ${cls}`, + attrs: { type: "text", "aria-label": tf(label) }, + }); + input.value = form[key]; + input.addEventListener("input", () => (form[key] = input.value)); + input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st)); + return el("label", { + className: "m02-dfpanel-fieldbox", + children: [el("span", { className: "m02-dfpanel-note", text: tf(label) }), input], + }); +} + +function drawVars(st: State): void { + const f = funcOf(st); + const row: HTMLElement[] = [el("span", { className: "m02-dfpanel-label", text: tf("Vars") })]; + for (const v of f?.variables ?? []) { + const chip = el("span", { + className: "m02-dfpanel-chip is-var", + children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`], + }); + if (!st.readOnly) { + chip.append( + createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => + patchFunc(st, (x) => ({ + ...x, + variables: x.variables.filter((o) => o.name !== v.name), + })), + }), + ); + chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`); + } + row.push(chip); + } + if (!f?.variables.length) + row.push(el("span", { className: "m02-dfpanel-note", text: tf("NoVars") })); + const parts: HTMLElement[] = [el("div", { className: "m02-dfpanel-row", children: row })]; + if (!st.readOnly) { + const form = st.form; + if (!form) { + parts.push( + el("div", { + className: "m02-dfpanel-row", + children: [ + createButton({ + label: tf("MakeVar"), + variant: "ghost", + size: "sm", + onClick: () => { + st.form = { + name: "", + type: "number", + unit: "", + min: "", + max: "", + choices: "", + example: "", + why: "", + }; + drawVars(st); + }, + }), + ], + }), + ); + } else { + const type = el("select", { + className: "ui-select m02-dfpanel-field", + attrs: { "aria-label": tf("VarType") }, + }); + for (const [v, k] of [ + ["number", "Number"], + ["choice", "Choice"], + ["text", "Text"], + ] as const) { + type.append(el("option", { attrs: { value: v }, text: tf(k) })); + } + type.value = form.type; + type.addEventListener("change", () => { + form.type = type.value as CfVariable["type"]; + drawVars(st); + }); + parts.push( + el("div", { + className: "m02-dfpanel-row m02-dfpanel-form", + children: [ + formField(st, "VarName", "name"), + el("label", { + className: "m02-dfpanel-fieldbox", + children: [el("span", { className: "m02-dfpanel-note", text: tf("VarType") }), type], + }), + formField(st, "VarUnit", "unit"), + ...(form.type === "number" + ? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")] + : []), + ...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []), + formField(st, "VarExample", "example"), + el("span", { className: "m02-dfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }), + createButton({ + label: tf("Make"), + variant: "filled", + size: "sm", + onClick: () => makeVariable(st), + }), + createButton({ + label: tf("Cancel"), + variant: "ghost", + size: "sm", + onClick: () => ((st.form = null), drawVars(st)), + }), + ...(form.why + ? [el("span", { className: "m02-dfpanel-why", text: `⚠ ${form.why}` })] + : []), + ], + }), + ); + } + } + st.parts.vars.replaceChildren(...parts); +} + +function drawBar(st: State): void { + const f = funcOf(st); + if (st.readOnly) { + st.parts.bar.replaceChildren( + el("span", { className: "m02-dfpanel-note", text: tf("ReadOnly") }), + ); + return; + } + const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone"); + st.parts.bar.replaceChildren( + el("div", { + className: "m02-dfpanel-row", + children: [ + createButton({ + label: tf("OutBtn"), + variant: "ghost", + size: "sm", + onClick: () => setOutput(st, st.cell), + }), + el("span", { className: "m02-dfpanel-chip is-out", text: outNow }), + el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }), + ], + }), + el("div", { + className: "m02-dfpanel-row", + children: [ + el("span", { className: "m02-dfpanel-label", text: tf("Frames") }), + ...FRAMES.map(([key, formula]) => + createButton({ + label: tf(key), + variant: "ghost", + size: "sm", + onClick: () => putFormula(st, formula), + }), + ), + el("span", { className: "m02-dfpanel-note", text: tf("FrameHint") }), + ], + }), + ); +} + +/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ +function setOutput(st: State, cell: string): void { + const f = funcOf(st); + if (!f) return; + if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) { + return void showToast(tf("Busy"), "error"); + } + const row = cell.replace(/^[A-Z]+/, ""); + const label = f.sheet.칸[`A${row}`]; + const name = String(label?.값 ?? f.output.name ?? "").trim() || cell; + patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } })); +} + +/* ------------------------------ 편집기 ------------------------------ */ + +/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ +function putFormula(st: State, formula: string): void { + const root = st.parts.sheet; + const bar = root.querySelector(".ss-formula"); + const addr = root.querySelector(".ss-addr"); + if (!bar || !addr || st.readOnly) return; + const at = st.cell; + bar.focus(); + bar.value = formula; + bar.dispatchEvent(new Event("input", { bubbles: true })); + bar.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), + ); + addr.value = at; + addr.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); +} + +/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */ +function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] { + for (const cell of Object.values(sheet.칸)) delete cell.서식; + return sheet; +} + +/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */ +function onBook(st: State, book: Workbook): void { + const f = funcOf(st); + if (!f) return; + const sheet = plain(structuredClone(book.시트[0])); + const hit = f.inputs.find( + (i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), + ); + if (hit) { + showToast(tf("Locked"), "error"); + st.json = sheetJson(f); + return st.handle?.setDoc(bookOf(f)); + } + st.json = JSON.stringify(sheet); + patchFunc(st, (x) => ({ ...x, sheet })); + setTimeout(() => refreshSnap(st), 0); +} + +/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ +function refreshSnap(st: State): void { + if (!st.handle) return; + st.snap = st.handle.recalc(); + st.redraw(); +} + +async function mountSheet(st: State): Promise { + const { createSpreadsheet } = await loadSheet(); + const f = funcOf(st); + if (!st.parts.sheet.isConnected || st.handle || !f) return; + st.parts.sheet.replaceChildren(); + st.json = sheetJson(f); + st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), { + preview: true, + tabs: false, + readOnly: st.readOnly, + onChange: (b) => onBook(st, b), + onSelect: (s) => { + st.cell = s.칸; + drawHead(st); + if (st.form) drawVars(st); + }, + }); + st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); + st.observer.observe(st.parts.sheet, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-addr"], + }); + st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault()); + st.parts.sheet.addEventListener("drop", (e) => { + e.preventDefault(); + const port = e.dataTransfer?.getData("text/plain"); + const cell = (e.target as Element | null)?.closest(".aislo-cell")?.dataset.addr; + if (port && cell) placePort(st, port, cell); + }); + refreshSnap(st); +} + +/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ +export function renderFuncPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: Handlers, + opts: { readOnly?: boolean } = {}, +): void { + const old = states.get(host); + const readOnly = !!opts.readOnly; + if (old && old.boxId === boxId && old.readOnly === readOnly) { + old.lg = lg; + old.on = on; + const f = funcOf(old); + if (old.handle && f && sheetJson(f) !== old.json) { + old.json = sheetJson(f); + old.handle.setDoc(bookOf(f)); + setTimeout(() => refreshSnap(old), 0); + return; + } + return old.redraw(); + } + if (old) { + old.observer?.disconnect(); + old.handle?.destroy(); + } + const parts: State["parts"] = { + head: el("div", { className: "m02-dfpanel-head" }), + got: el("div", { className: "m02-dfpanel-row" }), + vars: el("div", { className: "m02-dfpanel-vars" }), + bar: el("div", { className: "m02-dfpanel-bar" }), + msg: el("p", { className: "m02-dfpanel-msg", attrs: { role: "status" } }), + sheet: el("div", { className: "m02-dfpanel-sheet", text: tf("Loading") }), + }; + const st: State = { + boxId, + lg, + on, + readOnly, + handle: null, + snap: {}, + json: "", + cell: "A1", + form: null, + parts, + observer: null, + redraw: () => { + drawHead(st); + drawGot(st); + drawVars(st); + drawBar(st); + applyMarks(st); + }, + }; + states.set(host, st); + host.classList.add("m02-dfpanel"); + host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + parts.msg.hidden = true; + st.redraw(); + void mountSheet(st); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css new file mode 100644 index 000000000..3ac3c5845 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css @@ -0,0 +1,145 @@ +.m02-dfip { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + color: var(--color-text); + font-size: var(--text-body-lg, 1.05rem); +} +.m02-dfip h4, +.m02-dfip h5 { + margin: 0; + color: var(--color-text-secondary); +} +.m02-dfip h4 { + font-size: 1.15em; + color: var(--color-text); +} +.m02-dfip__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} +.m02-dfip__unit { + margin: 0; + font-weight: 600; +} +.m02-dfip__body, +.m02-dfip__chosen, +.m02-dfip__find, +.m02-dfip__stack, +.m02-dfip__list { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} +.m02-dfip__list { + max-height: 28vh; + overflow: auto; +} +.m02-dfip__chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} +.m02-dfip__chip, +.m02-dfip__row { + padding: var(--spacing-8) var(--spacing-12); + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; +} +.m02-dfip__chip { + border-radius: var(--radius-pills); + padding: var(--spacing-4) var(--spacing-12); +} +.m02-dfip__chip.is-on { + border-color: var(--color-accent); + outline: 2px solid var(--color-accent); +} +.m02-dfip__chip:hover, +.m02-dfip__chip:focus-visible, +.m02-dfip__row:hover, +.m02-dfip__row:focus-visible { + border-color: var(--color-accent); +} +.m02-dfip__row { + display: flex; + flex: 1 1 auto; + gap: var(--spacing-8); + align-items: center; + justify-content: space-between; + text-align: left; +} +.m02-dfip__rowbox { + display: flex; + gap: var(--spacing-8); + align-items: center; +} +.m02-dfip__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} +.m02-dfip__bar > :last-child { + flex: 1 1 180px; +} +.m02-dfip__item { + display: flex; + gap: var(--spacing-8); + align-items: center; + justify-content: space-between; + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} +.m02-dfip__name { + flex: 1 1 auto; + min-width: 0; +} +.m02-dfip__value { + color: var(--color-accent); +} +.m02-dfip__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-size: var(--text-caption); +} +.m02-dfip__new, +.m02-dfip__form { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); +} +.m02-dfip__new > summary { + cursor: pointer; + font-weight: 600; +} +.m02-dfip__preview table { + border-collapse: collapse; + font-size: var(--text-body-sm); +} +.m02-dfip__preview th, +.m02-dfip__preview td { + padding: 2px 8px; + border: 1px solid var(--color-border); + white-space: nowrap; + text-align: center; +} +.m02-dfip__preview tr.is-hit td { + background: rgba(240, 190, 40, 0.35); + font-weight: 700; +} +.m02-dfip__preview { + max-height: 24vh; + overflow: auto; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts new file mode 100644 index 000000000..34b1a7b72 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -0,0 +1,459 @@ +/* ============================================================================= + * M02_DF_InputPanel.ts + * 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) · + * 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위 + * M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/df/user-elements` = 시험 폴더 `_data`) + * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { + fetchFiles, + fetchRows, + fetchSubs, + type SubInfo, +} from "../../M01_MasterData/M01_MasterData_Api_Fetch"; +import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Api"; +import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model"; +import { + GROUPS, + M01_OF, + MACHINE_SEP, + USER_PREFIX, + addElement, + elementOf, + machineFields, + queryOf, + reconcileUnit, + removeElement, + setKind, + valueOf, + type M01Row, +} from "./M02_DF_InputPanel_Build"; +import { renderTablePick } from "./M02_DF_InputPanel_Table"; +import { renderVariables } from "./M02_DF_InputPanel_Vars"; +import "./M02_DF_InputPanel.css"; + +/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */ +interface Answer { + id: string; + ok: boolean; + value?: number | string | null; +} +interface UserEl { + id: string; + group: string; + name: string; + spec: string; + unit: string; + value: number | null; +} +const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise; +const apiUsers = cfUserElements as unknown as () => Promise; +const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise; + +const TEXT = { + Kind: ["무엇을 넣을까요?", "What goes in?"], + Variable: ["변수", "Variable"], + 인력: ["인력", "Labor"], + 재료: ["재료", "Material"], + 유가전력: ["유가·전력", "Fuel · power"], + 기계: ["기계", "Machine"], + 소요량: ["소요량", "Quantity table"], + 계수: ["계수", "Factor table"], + 환율: ["환율", "Exchange rate"], + 요율: ["요율", "Rate"], + Chosen: [ + "넣은 요소 — 알약마다 이름 · 값 · 단위", + "Chosen elements — name · value · unit per pill", + ], + NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"], + Remove: ["빼기", "Remove"], + Put: ["넣기", "Add"], + Search: ["이름으로 찾기", "Search by name"], + AllSub: ["전체", "All"], + SubLabel: ["구분", "Group"], + DetailLabel: ["상세구분", "Detail"], + Loading: ["찾는 중…", "Searching…"], + NoRow: ["찾는 요소 없음", "Nothing found"], + Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], + Mine: ["내가 만든 것", "Mine"], + NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"], + NewName: ["이름", "Name"], + NewSpec: ["규격", "Spec"], + NewUnit: ["단위", "Unit"], + NewValue: ["값(숫자)", "Value (number)"], + Make: ["만들어 넣기", "Create and add"], + NeedName: ["이름을 적어 주세요", "Enter a name"], + NeedValue: ["값은 숫자로", "Value must be a number"], + BoxUnit: ["상자 단위", "Box unit"], + Field: ["값 칸", "Value"], + NoValue: ["—", "—"], + Failed: ["불러오지 못함", "Could not load"], + Pills: ["알약 {n} 개", "{n} pills"], + NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); + +/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */ +const plain = (e: unknown): string => { + try { + return cfPlain(e); + } catch { + return e instanceof Error ? e.message : t("Failed"); + } +}; + +const fileCache = new Map(); +/** 그룹의 M01 파일 이름 */ +async function fileOf(group: CfGroup): Promise { + const hit = fileCache.get(group); + if (hit) return hit; + const { group: g, part } = M01_OF[group]; + const files = await fetchFiles(g); + const f = files.find((x) => !part || x.file.includes(part)) ?? files[0]; + fileCache.set(group, f.file); + return f.file; +} + +/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */ +export function renderInputPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, +): void { + host.className = "m02-dfip"; + const box = lg.boxes.find((b) => b.id === boxId); + if (!box || box.kind !== "input") { + host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("NotInput") })); + return; + } + // 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감 + const fixed = reconcileUnit(lg, boxId); + if (fixed !== lg) return on.change(fixed); + const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; + const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력"); + const body = el("div", { className: "m02-dfip__body" }); + + // ① 갈래 — 그룹 여덟 + 변수 + const kinds = el("div", { + className: "m02-dfip__chips", + attrs: { role: "tablist", "aria-label": t("Kind") }, + children: [...GROUPS, "변수" as const].map((k) => { + const b = el("button", { + className: `m02-dfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`, + attrs: { type: "button", role: "tab" }, + text: k === "변수" ? t("Variable") : t(k), + }); + b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k))); + return b; + }), + }); + const unit = el("p", { + className: "m02-dfip__unit", + text: `${t("BoxUnit")}: ${box.unit || "-"}`, + }); + host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); + + if (kind === "변수") renderVariables(body, lg, boxId, on); + else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); + else renderGroup(body, lg, box, kind, on); +} + +/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */ +function renderGroup( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): void { + const chosen = el("div", { className: "m02-dfip__chosen" }); + const find = el("div", { className: "m02-dfip__find" }); + host.replaceChildren(chosen, find, newForm(lg, box, group, on)); + drawChosen(chosen, lg, box, group, on); + void drawFind(find, lg, box, group, on); +} + +/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ +function drawChosen( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): void { + const els = box.input?.elements ?? []; + const values = new Map(); + host.replaceChildren( + el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }), + ...(els.length + ? els.map((e) => { + const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") }); + values.set(e.ref, val); + return el("div", { + className: "m02-dfip__item", + children: [ + el("span", { className: "m02-dfip__name", text: e.name }), + val, + el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(removeElement(lg, box.id, e.ref)), + }), + ], + }); + }) + : [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]), + ); + if (!els.length) return; + const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + void apiValues(items, lg.userElements).then( + (answers) => + answers.forEach((a) => { + const node = values.get(a.id); + if (node && a.ok && a.value !== null && a.value !== undefined) + node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value); + }), + // Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로) + () => + els.forEach( + (e) => + void m01Value(group, e).then((v) => { + const node = values.get(e.ref); + if (node && v !== null) node.textContent = fmt(v); + }), + ), + ); +} + +/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */ +async function m01Value(group: CfGroup, e: CfElementRef): Promise { + if (e.ref.startsWith(USER_PREFIX)) return null; + try { + const [key, field] = e.ref.split(MACHINE_SEP); + const page = await fetchRows( + await fileOf(group), + 1, + 30, + e.name.split(" · ")[0].split(" ")[0], + false, + "", + "", + ); + const row = (page.rows as M01Row[]).find((r) => r["키"] === key); + return row ? valueOf(group, row, field || "가격") : null; + } catch { + return null; + } +} + +/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */ +async function drawFind( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): Promise { + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m02-dfip__list" }); + const count = el("span", { className: "m02-dfip__muted" }); + const bar: HTMLElement[] = []; + let file = ""; + let sub = ""; + let detail = ""; + let subs: SubInfo[] = []; + const all = { value: "", text: t("AllSub") }; + const subBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("SubLabel"), + onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()), + }); + const detailBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("DetailLabel"), + onChange: (v) => ((detail = v), void load()), + }); + const fillDetail = (): void => { + const d = subs.find((s) => s.name === sub)?.details ?? []; + detailBox.root.hidden = !d.length; + detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], ""); + }; + fillDetail(); + let seq = 0; + const load = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); + try { + const word = q.input.value.trim(); + const [page, users] = await Promise.all([ + fetchRows(file, 1, 40, word, false, sub, detail), + apiUsers().catch((): UserEl[] => []), + ]); + if (mine !== seq) return; + const known = new Map(); + for (const u of [...lg.userElements, ...users]) + if (u.group === group) known.set(u.id, u as UserEl); + const mineRows: M01Row[] = [...known.values()] + .filter((u) => !word || `${u.name} ${u.spec}`.includes(word)) + .map((u) => ({ + 키: u.id, + 이름: u.name, + 규격: u.spec, + 단위: u.unit, + 값: u.value, + __mine: true, + })); + const rows = [...mineRows, ...(page.rows as M01Row[])]; + count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length }); + list.replaceChildren( + ...(rows.length + ? rows.map((r) => rowOf(r)) + : [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]), + ); + } catch (e) { + if (mine === seq) + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })); + } + }; + const rowOf = (r: M01Row): HTMLElement => { + const fields = group === "기계" ? machineFields(r) : []; + const field = createSelectField({ + options: fields.map((f) => ({ value: f, text: f })), + value: fields.includes("가격") ? "가격" : (fields[0] ?? ""), + compact: true, + ariaLabel: t("Field"), + }); + const pick = (): CfLogic => + addElement(lg, box.id, elementOf(group, r, field.select.value || "가격")); + const v = valueOf(group, r, field.select.value || "가격"); + const unitText = group === "기계" ? "" : String(r["단위"] ?? ""); + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("strong", { text: String(r["이름"] ?? "") }), + el("span", { className: "m02-dfip__muted", text: ` ${r["규격"] ?? ""}` }), + ...(r["__mine"] ? [el("span", { className: "m02-dfip__tag", text: t("Mine") })] : []), + ], + }), + el("span", { + className: "m02-dfip__muted", + text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(), + }), + ], + }); + b.addEventListener("click", () => on.change(pick())); + return el("div", { + className: "m02-dfip__rowbox", + children: [b, ...(fields.length ? [field.root] : [])], + }); + }; + try { + file = await fileOf(group); + subs = await fetchSubs({ file }).catch(() => []); + subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], ""); + subBox.root.hidden = !subs.length; + bar.push(subBox.root, detailBox.root); + } catch (e) { + host.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })); + return; + } + let timer = 0; + q.input.addEventListener( + "input", + () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), + ); + host.replaceChildren( + el("div", { className: "m02-dfip__bar", children: [...bar, q.root] }), + count, + list, + ); + void load(); + void USER_PREFIX; +} + +/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */ +function newForm( + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): HTMLElement { + const name = createInputField({ type: "text", label: t("NewName"), compact: true }); + const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true }); + const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true }); + const value = createInputField({ type: "text", label: t("NewValue"), compact: true }); + const go = createButton({ + label: t("Make"), + onClick: () => { + const n = + value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, "")); + if (!name.input.value.trim()) return name.setError(t("NeedName")); + if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue")); + name.setError(undefined); + value.setError(undefined); + apiAdd({ + group, + name: name.input.value.trim(), + spec: spec.input.value.trim(), + unit: unit.input.value.trim(), + value: n, + }).then( + (made) => { + const el1 = elementOf(group, { + 키: made.id, + 이름: made.name, + 규격: made.spec, + 단위: made.unit, + 값: made.value, + }); + const withUser: CfLogic = { + ...lg, + userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }], + }; + on.change(addElement(withUser, box.id, el1)); + }, + (e) => showToast(plain(e), "error"), + ); + }, + }); + const d = el("details", { + className: "m02-dfip__new", + children: [ + el("summary", { text: t("NewHead") }), + name.root, + spec.root, + unit.root, + value.root, + go, + ], + }); + return d; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts new file mode 100644 index 000000000..c55f0fd3e --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -0,0 +1,294 @@ +/* ============================================================================= + * M02_DF_InputPanel_Build.ts + * 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic) + * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts` + * ========================================================================== */ + +import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_DF_Model"; + +/** 입력 상자가 고르는 그룹 — 화면 차례 */ +export const GROUPS: CfGroup[] = [ + "인력", + "재료", + "유가전력", + "기계", + "소요량", + "계수", + "환율", + "요율", +]; + +/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */ +export const M01_OF: Record = { + 인력: { group: "인력", part: "" }, + 재료: { group: "재료", part: "자재품목" }, + 유가전력: { group: "재료", part: "유가전력" }, + 기계: { group: "기계", part: "" }, + 소요량: { group: "소요량", part: "" }, + 계수: { group: "계수", part: "" }, + 환율: { group: "환율", part: "" }, + 요율: { group: "요율", part: "" }, +}; + +/** M01 줄 한 개 — 파일 그대로(한글 칸) */ +export type M01Row = Record; + +/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */ +export const USER_PREFIX = "UE-"; + +/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */ +export const MACHINE_SEP = "#"; +export const machineFields = (row: M01Row): string[] => + row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : []; + +const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); +const dictUnit = (row: M01Row, field: string): string => + row["단위"] && typeof row["단위"] === "object" + ? text((row["단위"] as Record)[field]) + : ""; + +/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */ +export function lowest(row: M01Row): number | null { + const found = ["물가자료", "유통물가", "물가정보", "거래가격"] + .map((k) => row[k]) + .filter((v): v is number => typeof v === "number"); + return found.length ? Math.min(...found) : null; +} + +/** 줄 옆에 보일 값 — 모르면 null */ +export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null { + if (group === "재료") return lowest(row); + if (group === "기계") + return typeof (row["값"] as Record | undefined)?.[field] === "number" + ? (row["값"] as Record)[field] + : null; + if (group === "요율") { + const v = row["값"] as Record | undefined; + return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null; + } + return typeof row["값"] === "number" ? row["값"] : null; +} + +/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */ +export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef { + const key = text(row["키"]); + const spec = text(row["규격"]); + if (key.startsWith(USER_PREFIX)) + return { + ref: key, + name: [text(row["이름"]), spec].filter(Boolean).join(" "), + spec, + unit: text(row["단위"]) || "-", + }; + // 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름 + const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec; + const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게) + if (group === "기계") + return { + ref: `${key}${MACHINE_SEP}${field}`, + name: `${named} · ${field}`, + spec, + unit: dictUnit(row, field) || "-", + }; + if (group === "요율") + return { + ref: key, + name: text(row["이름"]), + spec: "", + unit: valueOf(group, row) === null ? "-" : "%", + }; + return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" }; +} + +/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */ +export function pillNames(box: CfBox): string[] { + const i = box.input; + if (!i) return []; + if (i.table) return ["표"]; + return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); +} + +/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */ +export function queryOf(group: CfGroup, ref: string): Record { + if (group === "기계" && !ref.startsWith(USER_PREFIX)) { + const [key, field] = ref.split(MACHINE_SEP); + return { group, ref: key, field: field || "가격" }; + } + return { group, ref }; +} + +/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ +export function unitOfInput(input: CfInput): string { + if (input.table) return "-"; + const units = ( + input.mode === "variable" + ? input.variables.map((v) => v.unit) + : input.elements.map((e) => e.unit) + ).map((u) => u || "-"); + return units.length && units.every((u) => u === units[0]) ? units[0] : "-"; +} + +/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkVariable(v: CfVariable, others: CfVariable[]): string | null { + const name = v.name.trim(); + if (!name) return "이름을 적어 주세요"; + if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음"; + if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상"; + if (v.type === "number") { + if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함"; + if ( + typeof v.example === "number" && + v.range && + (v.example < v.range[0] || v.example > v.range[1]) + ) + return "예시값이 범위 밖"; + if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자"; + } + if ( + v.type === "choice" && + v.example !== null && + !(v.choices ?? []).map(String).includes(String(v.example)) + ) + return "예시값이 선택 목록에 없음"; + return null; +} + +/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */ + +type Pills = (box: CfBox) => string[]; +const defaultPills: Pills = pillNames; + +const touch = ( + lg: CfLogic, + id: string, + fn: (b: CfBox) => CfBox, + pills: Pills = defaultPills, +): CfLogic => { + const old = lg.boxes.find((b) => b.id === id); + if (!old) return lg; + const next = fn(old); + const boxes = lg.boxes.map((b) => + b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b, + ); + // 알약이 없어진 선은 지움 + const keep = new Set(pills(next)); + const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name)); + return { ...lg, boxes, links }; +}; + +const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; + +/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ +export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => { + const cur = inputOf(b); + if (kind === "변수") + return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } }; + const same = cur.mode === "group" && cur.group === kind; + return { + ...b, + input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] }, + }; + }, + pills, + ); +} + +/** 요소 넣기 — 같은 요소는 한 번만 */ +export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => { + const cur = inputOf(b); + return cur.elements.some((e) => e.ref === el.ref || e.name === el.name) + ? b + : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; + }, + pills, + ); +} + +export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => ({ + ...b, + input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) }, + }), + pills, + ); +} + +/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */ +export function setTable( + lg: CfLogic, + id: string, + table: { key: string; row?: number } | null, + name?: string, + pills?: Pills, +): CfLogic { + const next = touch( + lg, + id, + (b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }), + pills, + ); + return name + ? { + ...next, + boxes: next.boxes.map((b) => + b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b, + ), + } + : next; +} + +/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */ +export function putVariable( + lg: CfLogic, + id: string, + v: CfVariable, + oldName?: string, + pills?: Pills, +): CfLogic { + return touch( + lg, + id, + (b) => { + const cur = inputOf(b); + const vars = + oldName === undefined + ? [...cur.variables, v] + : cur.variables.map((x) => (x.name === oldName ? v : x)); + return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } }; + }, + pills, + ); +} + +export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => ({ + ...b, + input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) }, + }), + pills, + ); +} + +/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */ +export function reconcileUnit(lg: CfLogic, id: string): CfLogic { + const box = lg.boxes.find((b) => b.id === id); + if (!box?.input) return lg; + const want = unitOfInput(box.input); + return box.unit === want + ? lg + : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts new file mode 100644 index 000000000..00a852a7a --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts @@ -0,0 +1,323 @@ +/* ============================================================================= + * M02_DF_InputPanel_Table.ts + * 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나) + * `M02_DF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import type { CfGroup, CfLogic } from "./M02_DF_Model"; +import { setTable } from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Book: ["적용 품셈", "Standard"], + Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], + Loading: ["찾는 중…", "Searching…"], + NoSection: ["찾는 절 없음", "No section"], + SecLine: ["표 {t} 개", "{t} tables"], + Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], + NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"], + TableLine: [ + "{b} · 조건 {c} · 결과 {v} · {n} 줄", + "{b} · {c} conditions · {v} results · {n} rows", + ], + Chosen: ["고른 표", "Chosen table"], + Whole: [ + "표 통째 — 함수 상자가 조건에 맞는 줄을 찾음", + "Whole table — a function box looks it up", + ], + OneRow: ["줄 하나 고정", "Fix one row"], + RowNo: ["몇 번째 줄", "Row number"], + Preview: ["미리 보기", "Preview"], + Notes: ["주(註)", "Notes"], + Remove: ["표 빼기", "Remove table"], + Change: ["다른 표 고르기", "Pick another table"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +async function get(path: string, params: Record): Promise { + const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { + credentials: "include", + }); + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!res.ok) throw new Error(String(data.detail ?? res.statusText)); + return data; +} + +interface SectionItem { + section: string; + title: string; + chapter: string; + tables: number; +} +interface TableHead { + file: string; + 키: string; + 이름: string; + 기준: string; + 조건: Record; + 값칸: Record; + count: number; +} +interface RawTable { + 키: string; + 원문번호: string; + 이름: string; + 기준: string; + 조건: Record; + 값칸: Record; + 줄: Record[]; + 주?: string[]; +} + +const BOOKS = ["산림품셈", "건설품셈"]; +const files = new Map(); +const tables = new Map(); + +/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */ +export async function loadTable(group: CfGroup, key: string): Promise { + const hit = tables.get(key); + if (hit) return hit; + let file = files.get(key); + if (!file) { + const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find( + (x) => x.키 === key, + ); + if (!found) throw new Error(t("Failed")); + file = found.file; + } + const raw = (await get<{ table: RawTable }>("/table", { file, key })).table; + tables.set(key, raw); + return raw; +} + +const cell = (v: unknown): string => + Array.isArray(v) + ? v.map((x) => x ?? "").join("~") + : v === null || v === undefined + ? "" + : String(v); + +/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */ +function previewOf(tb: RawTable, row?: number): HTMLElement { + const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; + const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1)); + return el("div", { + className: "m02-dfip__preview", + children: [ + el("table", { + children: [ + el("tr", { children: cols.map((c) => el("th", { text: c })) }), + ...body.map((r, i) => + el("tr", { + className: i === row ? "is-hit" : "", + children: cols.map((c) => el("td", { text: cell(r[c]) })), + }), + ), + ], + }), + ...(tb.줄.length > body.length + ? [el("p", { className: "m02-dfip__muted", text: `… ${tb.줄.length} 줄` })] + : []), + ...(tb.주?.length + ? [ + el("details", { + children: [ + el("summary", { text: t("Notes") }), + ...tb.주.map((n) => el("p", { text: n })), + ], + }), + ] + : []), + ], + }); +} + +/** 소요량 · 계수 표 고르기 화면 */ +export function renderTablePick( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + const cur = box?.input?.table; + if (cur) return void showChosen(host, lg, boxId, group, cur, on); + let book = BOOKS[0]; + const bookBox = createSelectField({ + label: t("Book"), + options: BOOKS.map((b) => ({ value: b, text: b })), + value: book, + compact: true, + onChange: (v) => ((book = v), load()), + }); + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m02-dfip__list" }); + const tableList = el("div", { className: "m02-dfip__list" }); + let seq = 0; + const load = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); + try { + const r = await get<{ sections: SectionItem[] }>("/sections", { + book, + q: q.input.value.trim(), + limit: "60", + }); + if (mine !== seq) return; + list.replaceChildren( + ...(r.sections.length + ? r.sections.map((s) => { + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("strong", { text: `${s.section} ${s.title}` }), + el("span", { + className: "m02-dfip__muted", + text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, + }), + ], + }); + b.addEventListener("click", () => void pickSection(s)); + return b; + }) + : [el("p", { className: "m02-dfip__muted", text: t("NoSection") })]), + ); + } catch (e) { + if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error"); + } + }; + const pickSection = async (s: SectionItem): Promise => { + try { + const heads = ( + await get<{ tables: TableHead[] }>("/tables", { + group, + section: s.section, + sub: book, + size: "100", + }) + ).tables; + tableList.replaceChildren( + el("h5", { text: t("Tables") }), + ...(heads.length + ? heads.map((h) => { + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("strong", { text: h.이름 }), + el("span", { + className: "m02-dfip__muted", + text: t("TableLine", { + b: h.기준 || "—", + c: Object.keys(h.조건).length, + v: Object.keys(h.값칸).length, + n: h.count, + }), + }), + ], + }); + b.addEventListener("click", () => { + files.set(h.키, h.file); + void loadTable(group, h.키).then( + (tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)), + (e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"), + ); + }); + return b; + }) + : [el("p", { className: "m02-dfip__muted", text: t("NoTable") })]), + ); + } catch (e) { + showToast(e instanceof Error ? e.message : t("Failed"), "error"); + } + }; + let timer = 0; + q.input.addEventListener( + "input", + () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), + ); + host.replaceChildren(bookBox.root, q.root, list, tableList); + void load(); +} + +/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */ +function showChosen( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + cur: { key: string; row?: number }, + on: { change(lg: CfLogic): void }, +): void { + const body = el("div", { className: "m02-dfip__stack" }); + host.replaceChildren(body); + void loadTable(group, cur.key).then( + (tb) => { + const whole = cur.row === undefined; + const modeWhole = createButton({ + label: t("Whole"), + variant: whole ? "filled" : "ghost", + onClick: () => on.change(setTable(lg, boxId, { key: cur.key })), + }); + const modeOne = createButton({ + label: t("OneRow"), + variant: whole ? "ghost" : "filled", + onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })), + }); + const rowBox = createSelectField({ + label: t("RowNo"), + options: tb.줄.map((r, i) => ({ + value: String(i), + text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)] + .slice(0, 4) + .map((c) => cell(r[c])) + .join(" / ")}`, + })), + value: String(cur.row ?? 0), + compact: true, + onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })), + }); + body.replaceChildren( + el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }), + el("div", { className: "m02-dfip__chips", children: [modeWhole, modeOne] }), + ...(whole ? [] : [rowBox.root]), + el("h5", { text: t("Preview") }), + previewOf(tb, cur.row), + el("div", { + className: "m02-dfip__chips", + children: [ + createButton({ + label: t("Change"), + variant: "ghost", + onClick: () => on.change(setTable(lg, boxId, null)), + }), + ], + }), + ); + }, + (e) => + body.replaceChildren( + el("p", { + className: "m02-dfip__muted", + text: e instanceof Error ? e.message : t("Failed"), + }), + ), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts new file mode 100644 index 000000000..3c0d9cd0f --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * M02_DF_InputPanel_Vars.ts + * 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값 + * 변수마다 출력 알약 하나 · `M02_DF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import type { CfLogic, CfVariable } from "./M02_DF_Model"; +import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build"; + +const TEXT = { + List: ["변수 — 알약마다 하나", "Variables — one pill each"], + None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"], + Name: ["이름", "Name"], + Type: ["갈래", "Type"], + Number: ["숫자", "Number"], + Choice: ["선택", "Choice"], + Text: ["글", "Text"], + Unit: ["단위", "Unit"], + Min: ["범위 아래", "Range from"], + Max: ["범위 위", "Range to"], + Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"], + Example: ["예시값", "Example"], + Add: ["변수 넣기", "Add variable"], + Save: ["고치기 끝", "Save change"], + Edit: ["고치기", "Edit"], + Remove: ["삭제", "Remove"], + Cancel: ["취소", "Cancel"], + Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"], + Range: ["범위 {a}~{b}", "range {a}~{b}"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +const numOf = (s: string): number | null => + s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null; + +/** 변수 한 줄 글 */ +export function variableLine(v: CfVariable): string { + const base = t("Line", { + n: v.name, + u: v.unit || "-", + e: v.example === null ? "—" : String(v.example), + }); + const range = + v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : ""; + const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : ""; + return `${base}${range}${choices}`; +} + +export function renderVariables( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + const vars = box?.input?.variables ?? []; + let editing: CfVariable | null = null; + + const form = (): HTMLElement => { + const cur = editing; + const name = createInputField({ + type: "text", + label: t("Name"), + value: cur?.name ?? "", + placeholder: "예: 면적", + compact: true, + }); + const type = createSelectField({ + label: t("Type"), + options: [ + { value: "number", text: t("Number") }, + { value: "choice", text: t("Choice") }, + { value: "text", text: t("Text") }, + ], + value: cur?.type ?? "number", + compact: true, + onChange: () => paint(), + }); + const unit = createInputField({ + type: "text", + label: t("Unit"), + value: cur?.unit ?? "", + placeholder: "예: ㎡", + compact: true, + }); + const lo = createInputField({ + type: "text", + label: t("Min"), + value: cur?.range ? String(cur.range[0]) : "", + compact: true, + }); + const hi = createInputField({ + type: "text", + label: t("Max"), + value: cur?.range ? String(cur.range[1]) : "", + compact: true, + }); + const choices = createInputField({ + type: "text", + label: t("Choices"), + value: (cur?.choices ?? []).join(", "), + compact: true, + }); + const example = createInputField({ + type: "text", + label: t("Example"), + value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example), + compact: true, + }); + const extra = el("div", { className: "m02-dfip__stack" }); + const paint = (): void => { + const ty = type.select.value; + extra.replaceChildren( + ...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []), + ); + }; + paint(); + const submit = (): void => { + const ty = type.select.value as CfVariable["type"]; + const ex = example.input.value.trim(); + const v: CfVariable = { + name: name.input.value.trim(), + type: ty, + unit: unit.input.value.trim(), + example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex, + ...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null + ? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] } + : {}), + ...(ty === "choice" + ? { + choices: choices.input.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } + : {}), + }; + const why = checkVariable( + v, + vars.filter((x) => x.name !== cur?.name), + ); + name.setError(why ?? undefined); + if (why) return; + on.change(putVariable(lg, boxId, v, cur?.name)); + }; + const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit }); + const cancel = cur + ? createButton({ + label: t("Cancel"), + variant: "ghost", + onClick: () => ((editing = null), draw()), + }) + : null; + return el("div", { + className: "m02-dfip__form", + children: [ + name.root, + type.root, + unit.root, + extra, + example.root, + el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }), + ], + }); + }; + + const draw = (): void => { + const list = vars.length + ? vars.map((v) => + el("div", { + className: "m02-dfip__item", + children: [ + el("span", { text: variableLine(v) }), + createButton({ + label: t("Edit"), + variant: "ghost", + size: "sm", + onClick: () => ((editing = v), draw()), + }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(removeVariable(lg, boxId, v.name)), + }), + ], + }), + ) + : [el("p", { className: "m02-dfip__muted", text: t("None") })]; + host.replaceChildren(el("h5", { text: t("List") }), ...list, form()); + }; + draw(); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts new file mode 100644 index 000000000..3613bf589 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -0,0 +1,329 @@ +/* ============================================================================= + * M02_DF_Model.ts + * 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나) + * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +export type CfKind = "input" | "function" | "output"; +export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율"; +export type CfCost = "노무비" | "재료비" | "경비" | "값"; + +/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */ +export interface CfElementRef { + ref: string; + name: string; + spec: string; + unit: string; +} + +/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */ +export interface CfVariable { + name: string; + type: "number" | "choice" | "text"; + unit: string; + choices?: (string | number)[]; + range?: [number, number]; + example: number | string | null; + /** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */ + cell?: string; +} + +export interface CfInput { + mode: "group" | "variable"; + group?: CfGroup; + elements: CfElementRef[]; + /** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */ + table?: { key: string; row?: number }; + variables: CfVariable[]; +} + +export interface CfFunc { + /** 우리 스프레드시트 시트 하나 */ + sheet: Sheet; + /** 받는 알약 → 칸 */ + inputs: { port: string; cell: string }[]; + variables: CfVariable[]; + /** 내보내는 알약 하나 */ + output: { cell: string; name: string; unit: string }; +} + +export interface CfOutput { + cost: CfCost; + unit: string; +} + +export interface CfBox { + id: string; + kind: CfKind; + name: string; + color: string; + /** 오른쪽 위 단위 — 기본 「-」 */ + unit: string; + x: number; + y: number; + input?: CfInput; + func?: CfFunc; + output?: CfOutput; +} + +/** 알약 한쪽 끝 */ +export interface CfPort { + box: string; + name: string; +} + +export interface CfLink { + id: string; + from: CfPort; + to: CfPort; +} + +/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */ +export interface CfUserElement { + id: string; + group: CfGroup; + name: string; + spec: string; + unit: string; + value: number | null; +} + +export interface CfLogic { + key: string | null; + name: string; + resultUnit: string; + boxes: CfBox[]; + links: CfLink[]; + userElements: CfUserElement[]; +} + +/** 알약 하나의 모습 */ +export interface CfPill { + name: string; + unit: string; +} + +const TEXT = { + Input: ["입력", "Input"], + Func: ["함수", "Function"], + Output: ["출력", "Output"], + Result: ["결과", "Result"], + Value: ["값", "Value"], + NoPill: ["알약이 없음", "No such pill"], + FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"], + ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"], + Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], + Taken: ["이미 선이 들어온 자리", "This spot already has a line"], + Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 종류별 기본 색 */ +export const BOX_COLOR: Record = { + input: "#2f80ed", + function: "#7b4fd6", + output: "#27ae60", +}; + +/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */ +export const OUTPUT_PORT = "값"; +export const TABLE_PORT = "표"; + +const clone = (v: T): T => structuredClone(v); + +/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */ +function nextId(prefix: string, ids: string[]): string { + const nums = ids + .filter((x) => x.startsWith(prefix)) + .map((x) => Number(x.slice(prefix.length))) + .filter((n) => Number.isInteger(n)); + return `${prefix}${Math.max(0, ...nums) + 1}`; +} + +/** 같은 이름이면 「 2」 「 3」 */ +function uniqueName(used: string[], base: string): string { + if (!used.includes(base)) return base; + for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`; +} + +const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); + +/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */ +export function updateBox( + lg: CfLogic, + id: string, + patch: Partial>, +): CfLogic { + return { + ...lg, + boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)), + }; +} + +/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ + +export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic { + return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] }; +} + +/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */ +export function addBox( + lg: CfLogic, + kind: CfKind, + x: number, + y: number, +): { lg: CfLogic; box: CfBox } { + const id = nextId( + "b", + lg.boxes.map((b) => b.id), + ); + const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output"); + const name = uniqueName( + lg.boxes.map((b) => b.name), + base, + ); + const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; + if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; + if (kind === "function") { + // 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음 + const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : [])); + let n = 1; + while (taken.includes(`${tl("Result")} ${n}`)) n++; + const out = `${tl("Result")} ${n}`; + box.func = { + sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } }, + inputs: [], + variables: [], + output: { cell: "B1", name: out, unit: "-" }, + }; + } + if (kind === "output") box.output = { cost: "노무비", unit: "-" }; + return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; +} + +/** 상자 지우기 — 이어진 선도 지움 */ +export function removeBox(lg: CfLogic, id: string): CfLogic { + // 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리 + let next = lg; + for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id); + return { ...next, boxes: next.boxes.filter((b) => b.id !== id) }; +} + +export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { + return updateBox(lg, id, { x, y }); +} + +export function setColor(lg: CfLogic, id: string, color: string): CfLogic { + return updateBox(lg, id, { color }); +} + +/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */ +export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { + const box = boxOf(lg, id); + if (!box) return lg; + const u = unit.trim() || "-"; + const patch: Partial = { unit: u }; + if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } }; + if (box.output) patch.output = { ...box.output, unit: u }; + return updateBox(lg, id, patch); +} + +/* ── 알약 ───────────────────────────────────────────────────────────────── */ + +/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name + * 함수 = func.output.name 하나 · 출력 = 없음 */ +export function portsOut(box: CfBox): CfPill[] { + if (box.kind === "function" && box.func) + return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; + if (box.kind !== "input" || !box.input) return []; + const inp = box.input; + return [ + ...(inp.mode === "group" + ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) + : []), + ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), + ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), + ]; +} + +/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */ +export function portsIn(box: CfBox): CfPill[] { + if (box.kind === "function" && box.func) + return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); + if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; + return []; +} + +/* ── 선 ─────────────────────────────────────────────────────────────────── */ + +/** from 상자에서 선을 따라 to 상자에 닿나 */ +export function reaches(lg: CfLogic, from: string, to: string): boolean { + const seen = new Set(); + const walk = (id: string): boolean => { + if (id === to) return true; + if (seen.has(id)) return false; + seen.add(id); + return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box)); + }; + return walk(from); +} + +/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 + * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · + * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ +export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { + const a = boxOf(lg, from.box); + const b = boxOf(lg, to.box); + if (!a || !b) return { lg, why: tl("NoPill") }; + if (a.kind === "output") return { lg, why: tl("FromOutput") }; + if (b.kind === "input") return { lg, why: tl("ToInput") }; + if (a.id === b.id) return { lg, why: tl("Self") }; + if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; + const has = portsIn(b).some((p) => p.name === to.name); + if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; + if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; + if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) + return { lg, why: tl("Taken") }; + let next = lg; + let port = to.name; + if (!has) { + const used = portsIn(b).map((p) => p.name); + const base = to.name.trim() || from.name; + port = used.includes(base) ? uniqueSuffix(used, base) : base; + const func = b.func!; + next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } }); + } + const id = nextId( + "l", + next.links.map((l) => l.id), + ); + return { + lg: { + ...next, + links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }], + }, + why: null, + }; +} + +/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ +function uniqueSuffix(used: string[], base: string): string { + for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`; +} + +/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */ +export function unlink(lg: CfLogic, id: string): CfLogic { + const gone = lg.links.find((l) => l.id === id); + const next = { ...lg, links: lg.links.filter((l) => l.id !== id) }; + const box = gone && boxOf(next, gone.to.box); + if (!gone || !box?.func) return next; + const port = box.func.inputs.find((i) => i.port === gone.to.name); + const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name); + if (!port || port.cell.trim() || still) return next; + const inputs = box.func.inputs.filter((i) => i !== port); + return updateBox(next, box.id, { func: { ...box.func, inputs } }); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css new file mode 100644 index 000000000..ab6b108ad --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css @@ -0,0 +1,85 @@ +.m02-dfout__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m02-dfout__body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m02-dfout__got { + min-width: 220px; + color: var(--color-text-body); +} + +.m02-dfout__got dl { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-4) var(--spacing-8); + margin: var(--spacing-4) 0 0; + font-size: var(--text-body-sm); +} + +.m02-dfout__got dt { + color: var(--color-text-secondary); +} + +.m02-dfout__got dd { + margin: 0; +} + +.m02-dfout__num { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m02-dfout__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); +} + +.m02-dfout__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-dfout__input { + box-sizing: border-box; + width: 160px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m02-dfout__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} + +.m02-dfout__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m02-dfout__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts new file mode 100644 index 000000000..9e182d999 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts @@ -0,0 +1,195 @@ +/* ============================================================================= + * M02_DF_OutputPanel.ts + * 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸 + * 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름 + * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval"; +import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_DF_Model"; +import "./M02_DF_OutputPanel.css"; + +const TEXT = { + Title: ["출력", "Output"], + Name: ["이름", "Name"], + Cost: ["비용 종류", "Cost kind"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Plain: ["값(비용 아님)", "Value (not a cost)"], + Unit: ["단위", "Unit"], + UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"], + Got: ["받은 값", "Received value"], + From: ["어디서", "From"], + Value: ["값", "Value"], + NoLink: [ + "아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요", + "Nothing is linked yet — draw a line from an input or function box", + ], + Table: ["표", "Table"], + Calc: ["계산 중이 아니에요", "Not calculated"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + PlainHint: [ + "원가 합계에 안 넣고 값으로만 보여요", + "Shown as a value, not added to the cost total", + ], +} as const; + +type TextKey = keyof typeof TEXT; +const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const COSTS: [CfCost, TextKey][] = [ + ["노무비", "Labor"], + ["재료비", "Material"], + ["경비", "Expense"], + ["값", "Plain"], +]; + +/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */ +export function showValue(v: CfValue | undefined, money = false): string { + if (v === undefined || v === null) return "—"; + if (Array.isArray(v)) return to("Table"); + if (typeof v === "number") + return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + return String(v); +} + +const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({ + ...lg, + boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)), +}); + +/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */ +export function renderOutputPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, + opts: { result?: CfEvalResult } = {}, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + if (!box || box.kind !== "output") { + host.replaceChildren(); + return; + } + const msg = el("p", { className: "m02-dfout__msg" }); + const emit = (make: () => CfLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = to("Cant"); + } + }; + const cost = box.output?.cost ?? "노무비"; + + /* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */ + const link = lg.links.find((l) => l.to.box === box.id); + const from = link && lg.boxes.find((b) => b.id === link.from.box); + let result = opts.result; + if (!result && link) { + try { + result = evalLogic(lg); + } catch { + result = undefined; + } + } + let unit = ""; + if (from && link) { + try { + unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit; + } catch { + unit = from.unit; + } + } + const got = el("div", { className: "m02-dfout__got" }); + if (link && from) { + const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id]; + got.replaceChildren( + el("strong", { text: to("Got") }), + el("dl", { + children: [ + el("dt", { text: to("From") }), + el("dd", { text: `${from.name} · ${link.from.name}` }), + el("dt", { text: to("Value") }), + el("dd", { + className: "m02-dfout__num", + text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`, + }), + ], + }), + ); + } else { + got.replaceChildren( + el("strong", { text: to("Got") }), + el("p", { className: "m02-dfout__warn", text: to("NoLink") }), + ); + } + + /* 이름 · 비용 종류 · 단위 */ + const nameBox = el("input", { + className: "m02-dfout__input", + attrs: { type: "text", "aria-label": to("Name") }, + }); + nameBox.value = box.name; + nameBox.addEventListener("change", () => { + const next = nameBox.value.trim(); + if (!next || next === box.name) { + nameBox.value = box.name; + return; + } + emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next }))); + }); + const costField = createSelectField({ + label: to("Cost"), + options: COSTS.map(([value, key]) => ({ value, text: to(key) })), + value: cost, + compact: true, + onChange: (v) => + emit(() => + withBox(lg, box.id, (b) => ({ + ...b, + output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost }, + })), + ), + }); + const unitBox = el("input", { + className: "m02-dfout__input", + attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") }, + }); + unitBox.value = box.output?.unit ?? box.unit; + unitBox.addEventListener("change", () => { + const next = unitBox.value.trim() || "-"; + emit(() => + withBox(lg, box.id, (b) => ({ + ...b, + unit: next, + output: { cost: b.output?.cost ?? "노무비", unit: next }, + })), + ); + }); + const field = (label: string, control: HTMLElement): HTMLElement => + el("label", { + className: "m02-dfout__field", + children: [el("span", { text: label }), control], + }); + + host.replaceChildren( + el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }), + el("div", { + className: "m02-dfout__body", + children: [ + got, + el("div", { + className: "m02-dfout__fields", + children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)], + }), + ], + }), + ...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []), + msg, + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Router.py b/M02_MasterTemplete/DataFlow/M02_DF_Router.py new file mode 100644 index 000000000..70f4d841a --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Router.py @@ -0,0 +1,664 @@ +"""단가산출 다이어그램 시험 길(PLAN 35-7) — 목록 · 열기 · 저장 · 지우기 · 입력 값 · 사용자 요소. + + GET /api/m02/df/list → {logics: [{key, name, resultUnit, saved, sample}]} + GET /api/m02/df/get?key=DF-0001 → {logic, sample} + POST /api/m02/df/save {logic} → {key, logic, values: {ports, outputs, cost, errors}} + POST /api/m02/df/delete {key} + POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]} + GET /api/m02/df/user-elements → {elements} + POST /api/m02/df/user-elements {op: add | delete, element? | id?} + +모양 = sub7 Model `M02_DF_Model.ts` +(CfLogic = key · name · resultUnit · boxes · links · userElements). +알약 이름(브레인 정함): 입력 상자가 내보내는 알약 = 요소 name · 표 「표」 · 변수 name / +함수 상자 받는 알약 = inputs[].port · 내보내는 알약 = output.name / 출력 상자 받는 알약 = 「값」. +저장 = 입력 값(노임 · 재료 · 유가전력 · 기계 · 환율 · 요율 · 소요량 / 계수 표 · 사용자 요소)을 +마스터(사용자 요소는 다이어그램 안 · `_data`)에서 다시 받아 → 함수 상자 시트들을 한 통합문서로 +묶어(받는 칸에 값 · 함수 → 함수는 시트 사이 참조) 우리 스프레드시트 엔진(Node · +`common_util_spreadsheet`)으로 셈 → 받는 칸에 값만 되돌려 넣어 `_data` 에 씀 · 브라우저 값은 +안 받아 적음. 단가는 기존 `/calc` 와 같은 셈. +자리 = 이 폴더 `_data/`(git 밖) · 견본 `_sample/`(git 안 · 읽기만 · 저장하면 새 번호) · +키 `DF-0001` · 사용자 요소 `UE-0001` · 마스터 파일은 안 씀. 권한 = 시스템 관리자(`main.py` 한 줄). +옛 시험판(SheetLogic) 서버를 옮겨 고쳐 씀 — 그 폴더를 import 하지 않음. +정함 = `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`. +""" + +from __future__ import annotations + +import copy +import json +import os +import re +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from common_util import common_util_spreadsheet as spreadsheet +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.M01_MasterData_Router import _num + +cm = store.cm +router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow (시험)"]) + +HERE = Path(__file__).resolve().parent +DATA = HERE / "_data" # 시험은 임시 폴더로 바꿈 +SAMPLE = HERE / "_sample" +USERS = "_user_elements.json" # 사용자 요소 공용 목록 — `_data` 안 +_KEY = re.compile(r"^DF-(\d{4,})$") +_UKEY = re.compile(r"^UE-(\d{4,})$") +_SAMPLE_KEY = re.compile(r"^[\w가-힣·() -]+$") +_CELL = re.compile(r"^([A-Z]+)(\d+)$") +RANGE_LOW, RANGE_HIGH = "·아래", "·위" +FIELDS = (*cm.mf.COST_ITEMS, "계") +GROUPS = ("인력", "재료", "유가전력", "기계", "소요량", "계수", "환율", "요율") +TABLE_PORT, OUTPUT_PORT = "표", "값" +COSTS = ("노무비", "재료비", "경비") + + +class LogicBody(BaseModel): + logic: dict[str, Any] + + +class KeyBody(BaseModel): + key: str + + +class ValuesBody(BaseModel): + items: list[dict[str, Any]] + userElements: list[dict[str, Any]] = [] + + +class UserBody(BaseModel): + op: str + element: dict[str, Any] | None = None + id: str | None = None + + +# ── 자리 ────────────────────────────────────────────────────────────── +def _write(path: Path, data: Any) -> None: + DATA.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") + os.replace(tmp, path) + + +def _mine(key: str) -> Path: + if not _KEY.match(key or ""): + raise store.StoreError(400, f"다이어그램 번호 모양이 아님 「{key}」") + return DATA / f"{key}.json" + + +def _sample(key: str) -> Path | None: + if not key or _KEY.match(key) or not _SAMPLE_KEY.match(key): + return None + path = SAMPLE / f"{key}.json" + return path if path.is_file() else None + + +def _read(key: str) -> tuple[dict, bool]: + """(다이어그램, 견본인가) — 내 것은 `_data` · 견본은 `_sample`.""" + path = _sample(key) + if path is not None: + return json.loads(path.read_text(encoding="utf-8")), True + path = _mine(key) + if not path.is_file(): + raise store.StoreError(404, f"없는 다이어그램 「{key}」") + return json.loads(path.read_text(encoding="utf-8")), False + + +def listing() -> dict: + out = [] + for path, sample in [ + *((p, False) for p in sorted(DATA.glob("DF-*.json"))), + *((p, True) for p in sorted(SAMPLE.glob("*.json"))), + ]: + logic = json.loads(path.read_text(encoding="utf-8")) + out.append( + { + "key": path.stem, + "name": logic.get("name") or path.stem, + "resultUnit": logic.get("resultUnit") or "", + "saved": logic.get("saved") or "", + "sample": sample, + } + ) + return {"logics": out} + + +def get(key: str) -> dict: + logic, sample = _read(key) + return {"logic": {**logic, "key": key}, "sample": sample} + + +def delete(key: str) -> dict: + if _sample(key) is not None: + raise store.StoreError(403, "견본은 지울 수 없음") + _read(key) + _mine(key).unlink() + return {"key": key} + + +# ── 사용자 요소(Model CfUserElement — id · group · name · spec · unit · value) ───── +def _users() -> list[dict]: + path = DATA / USERS + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else [] + + +def user_elements() -> dict: + return {"elements": _users()} + + +def user_edit(op: str, element: dict | None, uid: str | None) -> dict: + rows = _users() + if op == "delete": + if not any(r["id"] == uid for r in rows): + raise store.StoreError(404, f"없는 사용자 요소 「{uid}」") + _write(DATA / USERS, [r for r in rows if r["id"] != uid]) + return {"id": uid} + if op != "add" or not isinstance(element, dict): + raise store.StoreError(400, "op 는 add(element) 또는 delete(id)") + name = str(element.get("name") or "").strip() + if not name: + raise store.StoreError(400, "이름을 넣어 주세요") + group = element.get("group") or "재료" + if group not in GROUPS: + raise store.StoreError(400, f"모르는 그룹 「{group}」") + value = element.get("value") + if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))): + raise store.StoreError(400, "값은 수 또는 빈 값") + used = [int(m.group(1)) for r in rows if (m := _UKEY.match(r["id"]))] + one = { + "id": f"UE-{max(used, default=0) + 1:04d}", + "group": group, + "name": name, + "spec": str(element.get("spec") or ""), + "unit": str(element.get("unit") or ""), + "value": value, + } + _write(DATA / USERS, [*rows, one]) + return {"element": one} + + +# ── 입력 값 ──────────────────────────────────────────────────────────── +def _row(line: dict, names) -> dict: + """단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" + return { + "키": "", + "원문번호": "", + "이름": "입력 값", + "결과단위": "원/식", + "출처": "자체", + "입력": [{"이름": n} for n in names], + "중간": [], + "호표": [line], + "덧줄": [], + "끝수": None, + } + + +def _money(got: dict, field: str | None) -> dict: + if not got["ok"]: + return got + if "result" in got: + return {"ok": True, "value": got["result"]} + pick = field or "계" + if pick not in FIELDS: + return {"ok": False, "reason": f"모르는 칸 「{pick}」"} + return {"ok": True, "value": got["sums"][pick]} + + +def _pick(key: Any) -> dict | None: + """재료 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None.""" + if isinstance(key, dict): + return key + if isinstance(key, str) and key.lstrip().startswith("{"): + try: + got = json.loads(key) + except ValueError: + return None + return got if isinstance(got, dict) else None + return None + + +def _material(cond: dict, unit: str) -> dict: + """검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산).""" + line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"} + got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None) + if not got["ok"]: + return got + one = got["lines"][0] + if one["단가"] is None: + word = cond.get("검색어") or "" + empty = "값 없음" in str(one.get("까닭") or "") + why = ( + "단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름" + if empty + else "맞는 재료 없음" + ) + return {"ok": False, "reason": f"「{word}」 {why}"} + picked = one.get("품목") or {} + return {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None} + + +def _brief(key: str, want: str) -> tuple[dict | None, str | None]: + """마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키.""" + try: + row = store.loaded()[1].get(key) + except cm.mf.FormulaError as e: + return None, str(e) + group = store.mk.GROUP_OF.get(store.mk.id_of(key)) + if not (group == want or (want == "유가전력" and key.startswith("MO"))): + return None, f"「{key}」 는 {want} 아님" + return row, None + + +def _price(group: str, key: str, unit: str = "") -> dict: + """인력 노임 · 재료 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" + row, why = _brief(key, group) + if row is None: + return {"ok": False, "reason": why} + kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group] + line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"} + line = {**line, "단위": unit or row.get("단위") or "", "비목": cost} + got = store.calc("", {}, _row(line, ()), None) + if not got["ok"]: + return got + one = got["lines"][0] + out = {"ok": one["단가"] is not None, "value": one["단가"], "name": row.get("이름")} + return {**out, "unit": row.get("단위"), **({"reason": one["까닭"]} if one.get("까닭") else {})} + + +def _plain_value(group: str, key: str, field: str | None) -> dict: + """유가전력 · 환율 · 요율 · 기계 값 칸 — 그 줄 값(묶음이면 field · 요율 기본 rate_percent).""" + row, why = _brief(key, group) + if row is None: + return {"ok": False, "reason": why} + value, unit = row.get("값"), row.get("단위") + if isinstance(value, dict): + pick = field or ("rate_percent" if group == "요율" else "") + if pick not in value: + return { + "ok": False, + "reason": f"「{row.get('이름')}」 에서 쓸 칸을 고름 — {', '.join(value)}", + } + value = value[pick] + unit = ( + unit.get(pick) if isinstance(unit, dict) else ("%" if pick == "rate_percent" else unit) + ) + if value is None: + return {"ok": False, "reason": f"「{row.get('이름')}」 값이 자료에 비어 있음"} + return {"ok": True, "value": value, "name": row.get("이름"), "unit": unit} + + +def _table(key: str, at: Any = None) -> dict: + """소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 · + 그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나.""" + files = store.loaded()[0] + table = next( + ( + t + for d in files.values() + if d.get("그룹") in cm.mf.TABLE_GROUPS + for t in d.get("표") or [] + if str(t.get("키")) == key + ), + None, + ) + if table is None: + return {"ok": False, "reason": f"없는 표 「{key}」"} + conds, lines = table.get("조건") or {}, table.get("줄") or [] + names = [*conds, *(table.get("값칸") or {})] + split = { + n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) + } + head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))] + if at is not None: + if not isinstance(at, int) or not 0 <= at < len(lines): + return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"} + lines = [lines[at]] + rows = [] + for r in lines: + line = [] + for n in names: + v = r.get(n) + if n in split: + line += list(v) if isinstance(v, list) else [v, v] + else: + line.append(None if v == "" else v if not isinstance(v, dict) else str(v)) + rows.append(line) + return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} + + +def _user(uid: str, local: list[dict]) -> dict: + one = next((r for r in [*local, *_users()] if r.get("id") == uid), None) + if one is None: + return {"ok": False, "reason": f"없는 사용자 요소 「{uid}」"} + if one.get("value") is None: + return {"ok": False, "reason": f"「{one.get('name')}」 값이 비어 있음"} + return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")} + + +MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」 +HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝) +_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 + + +def _machine_family() -> list[dict]: + """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" + files = store.loaded()[0] + return [ + row + for name, data in files.items() + if data.get("그룹") == "로직" and "건설기계" in name + for row in data.get("줄") or [] + if str(row.get("결과단위") or "").startswith("원") + and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) + ] + + +def _machine_rank(row: dict) -> tuple: + """짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키.""" + names = [i.get("이름") for i in row.get("입력") or []] + codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계") + attach = any(n not in _MACHINE_PLAIN for n in names) + loss = "지역" not in names + return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키"))) + + +def _machine_hourly(key: str, inputs: dict) -> dict: + """기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기.""" + row, why = _brief(key, "기계") + if row is None: + return {"ok": False, "reason": why} + code = str(row.get("원문번호") or "") + fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]] + if not fit: + return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} + logic = min(fit, key=_machine_rank) + given = {} + for spec in logic.get("입력") or []: + name = spec.get("이름") + if name != "기계": + given[name] = inputs.get(name, (spec.get("고르기") or [None])[0]) + args = "".join(f", {n}='{v}'" for n, v in given.items()) + ref = f"로직({logic['키']}, 기계='{code}'{args})" + line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} + got = _money(store.calc("", {}, _row(line, ()), None), "계") + return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got + + +def _codes(row: dict) -> list: + return next( + (i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), [] + ) + + +def one_value(item: dict, local: list[dict] | None = None) -> dict: + """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. + + 기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈. + """ + group, ref = item.get("group"), item.get("ref") + table, field = item.get("table") or {}, item.get("field") + if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref: + ref, _, field = ref.partition(MACHINE_SEP) + try: + if isinstance(ref, str) and _UKEY.match(ref): + got = _user(ref, local or []) + elif group in ("소요량", "계수"): + got = _table(str(table.get("key") or ref or ""), table.get("row")) + elif group == "재료" and _pick(ref) is not None: + got = _material(_pick(ref), str(item.get("unit") or "")) + elif group in ("인력", "재료"): + got = _price(group, str(ref or ""), str(item.get("unit") or "")) + elif group == "기계" and str(ref or "").startswith("로직("): + inputs = item.get("inputs") or {} + line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} + got = _money(store.calc("", inputs, _row(line, inputs), None), field) + elif group == "기계" and field == HOURLY: + got = _machine_hourly(str(ref or ""), item.get("inputs") or {}) + elif group in ("기계", "유가전력", "환율", "요율"): + got = _plain_value(group, str(ref or ""), field) + else: + got = {"ok": False, "reason": f"모르는 그룹 「{group}」"} + except store.StoreError as e: + got = {"ok": False, "reason": str(e.detail)} + return {"id": item.get("id") or "", **got} + + +def values(items: list[dict], local: list[dict]) -> dict: + # ponytail: 하나씩 셈(단가 하나 0.3초쯤) — 입력 값이 수십이면 마스터 사본 한 벌로 묶어 셈 + return {"values": [one_value(i, local) for i in items]} + + +# ── 저장 · 계산 ──────────────────────────────────────────────────────── +def _col(letters: str) -> int: + n = 0 + for ch in letters: + n = n * 26 + ord(ch) - 64 + return n + + +def _letters(n: int) -> str: + out = "" + while n: + n, rest = divmod(n - 1, 26) + out = chr(65 + rest) + out + return out + + +def _style(cells: dict, cell: str) -> dict: + return {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {} + + +def _put(cells: dict, cell: str, value: Any) -> None: + """받는 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠).""" + keep = _style(cells, cell) + if value is None and not keep: + cells.pop(cell, None) + else: + cells[cell] = {**({"값": value} if value is not None else {}), **keep} + + +def _place(cells: dict, cell: str, value: Any) -> None: + """값 하나 · 표 블록(cell 이 왼위) · 수식 `{"식": …}` 을 받는 칸에.""" + m = _CELL.match(str(cell or "").upper()) + if not m: + raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」") + col, row, at = _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}" + if isinstance(value, dict) and "식" in value: + cells[at] = {**value, **_style(cells, at)} + elif isinstance(value, list): + for dy, line in enumerate(value): + for dx, one in enumerate(line): + _put(cells, f"{_letters(col + dx)}{row + dy}", one) + else: + _put(cells, at, value) + + +def _plain(v: Any) -> Any: + """엔진 계산값 한 칸 → 수 · 글 · 참거짓 · 빈 값(오류는 None).""" + if not isinstance(v, dict): + return v + if "수" in v: + return Decimal(str(v["수"])) + if "글" in v: + return v["글"] + if "참" in v: + return v["참"] + return None + + +def _source(links: list[dict], box: str, port: str) -> dict | None: + return next((ln["from"] for ln in links if ln.get("to") == {"box": box, "name": port}), None) + + +def _input_ports(box: dict, local: list[dict], errors: list[str]) -> dict[str, Any]: + """입력 상자 알약 값 — 요소 name · 표 「표」 · 변수 name(예시값).""" + inp, out = box.get("input") or {}, {} + if inp.get("mode") == "group": + group = inp.get("group") + asks = [ + (el.get("name"), {"ref": el.get("ref"), "unit": el.get("unit")}) + for el in inp.get("elements") or [] + ] + if inp.get("table"): + asks.append((TABLE_PORT, {"table": inp["table"]})) + for name, ask in asks: + got = one_value({"group": group, **ask}, local) + out[name] = (got.get("rows") or got.get("value")) if got["ok"] else None + if not got["ok"]: + errors.append(f"「{box.get('name')} · {name}」 {got.get('reason')}") + for v in inp.get("variables") or []: + out[v.get("name")] = v.get("example") + return out + + +def compute(logic: dict, local: list[dict]) -> tuple[dict, dict]: + """함수 상자 시트를 한 통합문서로 셈 → (값을 되돌려 넣은 다이어그램, 계산 결과).""" + boxes = {b.get("id"): b for b in logic.get("boxes") or []} + links = logic.get("links") or [] + errors: list[str] = [] + ports = { + i: _input_ports(b, local, errors) for i, b in boxes.items() if b.get("kind") == "input" + } + funcs = [b for b in boxes.values() if b.get("kind") == "function" and b.get("func")] + sheets, fills = [], [] + for b in funcs: # 한 통합문서 — 시트 id · 이름 = 상자 id + f = b["func"] + sheet = {**copy.deepcopy(f.get("sheet") or {}), "id": b["id"], "이름": b["id"]} + cells = sheet.setdefault("칸", {}) + for v in f.get("variables") or []: + if v.get("cell"): + _place(cells, v["cell"], v.get("example")) + for p in f.get("inputs") or []: + src = _source(links, b["id"], p.get("port")) + if src is None or src.get("box") not in boxes: + continue + up = boxes[src["box"]] + if up.get("kind") == "function": + at = ((up.get("func") or {}).get("output") or {}).get("cell") or "" + _place(cells, p["cell"], {"식": f"'{up['id']}'!{at}"}) + fills.append((b["id"], p["cell"], ("ref", up["id"], at))) + else: + value = ports.get(up["id"], {}).get(src.get("name")) + _place(cells, p["cell"], value) + fills.append((b["id"], p["cell"], ("value", value, None))) + sheets.append(sheet) + top = [c.get("서식", 0) for s in sheets for c in s["칸"].values() if isinstance(c, dict)] + book = _num( + {"종류": "통합문서", "판": 1, "서식": [{}] * (int(max(top, default=0)) + 1), "시트": sheets} + ) + try: + calc = spreadsheet.recalc_workbooks([book])[0] if sheets else {} + except spreadsheet.RecalcError as e: + raise store.StoreError(503, str(e)) from e + + def cell_value(box_id: str, cell: str) -> Any: + hit = (calc.get(box_id) or {}).get(cell) + if hit is None: + sheet = next(s for s in book["시트"] if s["id"] == box_id) + return (sheet["칸"].get(cell) or {}).get("값") + if isinstance(hit, dict) and "오류" in hit: + errors.append(f"「{boxes[box_id].get('name')}」 {cell} 칸 계산 오류 {hit.get('오류')}") + return _plain(hit) + + for b in funcs: + out = b["func"].get("output") or {} + ports[b["id"]] = {out.get("name"): cell_value(b["id"], out.get("cell") or "")} + outputs, cost = {}, {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}} + for b in boxes.values(): # 출력 상자 · 원가 + if b.get("kind") != "output": + continue + src = _source(links, b["id"], OUTPUT_PORT) + value = ports.get(src["box"], {}).get(src.get("name")) if src else None + outputs[b["id"]] = value + kind = (b.get("output") or {}).get("cost") + if ( + kind in COSTS + and isinstance(value, (int, float, Decimal)) + and not isinstance(value, bool) + ): + cost[kind] += Decimal(str(value)) + elif kind == "값": + cost["값들"][b.get("name")] = value + cost["계"] = sum(cost[k] for k in COSTS) + done = copy.deepcopy(logic) # 받는 칸에 값만 되돌려 넣음 — 시트 사이 참조는 셈한 값으로 + for b in done.get("boxes") or []: + if b.get("kind") != "function" or not b.get("func"): + continue + cells = b["func"].setdefault("sheet", {}).setdefault("칸", {}) + for v in b["func"].get("variables") or []: + if v.get("cell"): + _place(cells, v["cell"], v.get("example")) + for box_id, cell, (how, a, at) in fills: + if box_id == b["id"]: + _place(cells, cell, cell_value(a, at) if how == "ref" else a) + return done, {"ports": ports, "outputs": outputs, "cost": cost, "errors": errors} + + +def save(logic: dict) -> dict: + """저장 — 입력 값 다시 받기 → 함수 상자 시트를 Node 로 셈 → 값만 되돌려 넣어 `_data`.""" + if not str(logic.get("name") or "").strip(): + raise store.StoreError(400, "이름을 넣어 주세요") + if not isinstance(logic.get("boxes"), list) or not isinstance(logic.get("links"), list): + raise store.StoreError(400, "상자(boxes) · 선(links) 이 없음") + key = logic.get("key") or None + if key and _sample(key) is not None: + key = None # 견본은 그대로 두고 새 번호로 + elif key: + _read(key) # 있는 것만 고침 — 없으면 404 + done, result = compute(logic, logic.get("userElements") or []) + if not key: + used = [int(m.group(1)) for p in DATA.glob("DF-*.json") if (m := _KEY.match(p.stem))] + key = f"DF-{max(used, default=0) + 1:04d}" + done = {**done, "key": key, "saved": datetime.now().isoformat(timespec="seconds")} + _write(DATA / f"{key}.json", _num(done)) + return {"key": key, "logic": done, "values": result} + + +# ── 길 ──────────────────────────────────────────────────────────────── +def _call(fn, *args): + try: + return _num(fn(*args)) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + + +@router.get("/list") +def get_list() -> dict: + return _call(listing) + + +@router.get("/get") +def get_one(key: str) -> dict: + return _call(get, key) + + +@router.post("/save") +def post_save(body: LogicBody) -> dict: + return _call(save, body.logic) + + +@router.post("/delete") +def post_delete(body: KeyBody) -> dict: + return _call(delete, body.key) + + +@router.post("/values") +def post_values(body: ValuesBody) -> dict: + return _call(values, body.items, body.userElements) + + +@router.get("/user-elements") +def get_user_elements() -> dict: + return _call(user_elements) + + +@router.post("/user-elements") +def post_user_elements(body: UserBody) -> dict: + return _call(user_edit, body.op, body.element, body.id) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts new file mode 100644 index 000000000..73367bcaf --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * M02_DF_Sample.ts + * 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬) + * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비 + * ② 공구손료(비율) — 노무비 × 율 → 출력 재료비 + * ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값 + * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). + * 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」). + * 모양 = `M02_DF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port · + * output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { CfLogic } from "./M02_DF_Model"; + +const FILES = import.meta.glob("./_sample/*.json", { eager: true, import: "default" }); + +/** 견본마다 한 줄 설명 — [한, 영] */ +const NOTE: Record = { + masonry: [ + "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", + "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", + ], + rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material cost"], + value: [ + "q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)", + "q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)", + ], +}; + +/** 보여 줄 차례 */ +const ORDER = ["masonry", "rate", "value"]; + +export interface CfSampleItem { + /** 파일 이름(확장자 뺌) */ + id: string; + name: string; + resultUnit: string; + /** 쉬운 말 한 줄 */ + note: string; +} + +const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, ""); +const byId = (): Map => + new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg])); + +/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */ +export function sampleList(): CfSampleItem[] { + const all = byId(); + const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length); + return [...all.entries()] + .sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name)) + .map(([id, lg]) => ({ + id, + name: lg.name, + resultUnit: lg.resultUnit, + note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "", + })); +} + +/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */ +export function sampleLoad(id: string): CfLogic | null { + const lg = byId().get(id); + return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.css b/M02_MasterTemplete/DataFlow/M02_DF_Summary.css new file mode 100644 index 000000000..a4f155260 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Summary.css @@ -0,0 +1,132 @@ +.m02-dfsum__big { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m02-dfsum__big strong { + color: var(--color-text-body); + font-size: 28px; + font-variant-numeric: tabular-nums; +} + +.m02-dfsum__costs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-16); + margin: 0 0 var(--spacing-12); + font-size: var(--text-body-sm); +} + +.m02-dfsum__costs dt { + display: inline; + color: var(--color-text-secondary); +} + +.m02-dfsum__costs dd { + display: inline; + margin: 0 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m02-dfsum__block { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m02-dfsum__scroll { + max-width: 100%; + overflow-x: auto; +} + +.m02-dfsum__table { + width: 100%; + min-width: 360px; + margin-top: var(--spacing-4); + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m02-dfsum__table th, +.m02-dfsum__table td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m02-dfsum__table th { + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m02-dfsum__table td.m02-dfsum__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m02-dfsum__strip { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m02-dfsum__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m02-dfsum__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-dfsum__input { + box-sizing: border-box; + width: 140px; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m02-dfsum__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m02-dfsum__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-dfsum__problems { + margin: 0 0 var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m02-dfsum__problems ul { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts new file mode 100644 index 000000000..ec87048f9 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts @@ -0,0 +1,279 @@ +/* ============================================================================= + * M02_DF_Summary.ts + * 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄 + * · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈) + * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. + * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { checkLogic, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; +import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model"; +import { showValue } from "./M02_DF_OutputPanel"; +import "./M02_DF_Summary.css"; + +const TEXT = { + Per: ["{per} 당", "per {per}"], + Total: ["원가 합계", "Cost total"], + Won: ["원", "KRW"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Lines: ["출력 상자별", "By output box"], + HName: ["이름", "Name"], + HCost: ["비용 종류", "Cost kind"], + HValue: ["값", "Value"], + HUnit: ["단위", "Unit"], + Plain: ["값(비용 아님)", "Values (not costs)"], + Sample: ["예시값", "Sample values"], + SampleHint: [ + "여기서 바꾸면 변수 상자의 예시값도 바뀌어요", + "Changing here changes the variable box too", + ], + Problems: ["고칠 것", "To fix"], + NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"], + NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Pick: ["고르세요", "Choose"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + Plain3: ["값", "Value"], +} as const; + +type TextKey = keyof typeof TEXT; +const ts = (key: TextKey, fill: Record = {}): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace( + /\{(\w+)\}/g, + (m, k: string) => fill[k] ?? m, + ); + +const COSTS = ["노무비", "재료비", "경비"] as const; +const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; + +const costText = (c: string): string => (c === "값" ? ts("Plain3") : c); + +/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */ +function emptyPlaces(lg: CfLogic): string[] { + const outs = lg.boxes.filter((b) => b.kind === "output"); + if (!outs.length) return [ts("NoOutput")]; + return outs + .filter((o) => !lg.links.some((l) => l.to.box === o.id)) + .map((o) => ts("NoLink", { box: o.name })); +} + +/** 변수 상자의 예시값 칸 하나 */ +function sampleField( + v: CfVariable, + put: (value: number | string) => void, + msg: HTMLElement, +): HTMLElement { + const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); + let control: HTMLElement; + if (v.type === "choice" && v.choices?.length) { + control = createSelectField({ + options: [ + { value: "", text: ts("Pick") }, + ...v.choices.map((c) => ({ value: String(c), text: String(c) })), + ], + value: v.example === null ? "" : String(v.example), + compact: true, + ariaLabel: v.name, + onChange: (s) => { + const hit = v.choices?.find((c) => String(c) === s); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const box = el("input", { + className: "m02-dfsum__input", + attrs: { + type: "text", + inputmode: v.type === "number" ? "decimal" : "text", + "aria-label": v.name, + }, + }); + if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) }); + box.value = v.example === null ? "" : String(v.example); + box.addEventListener("change", () => { + const raw = box.value.trim(); + if (raw === "") return; + if (v.type !== "number") return put(raw); + const n = Number(raw); + if (Number.isNaN(n)) msg.textContent = ts("Bad"); + else put(n); + }); + control = box; + } + return el("label", { className: "m02-dfsum__field", children: [label, control] }); +} + +/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */ +export function renderSummary( + host: HTMLElement, + lg: CfLogic, + result: CfEvalResult, + on?: { change(lg: CfLogic): void }, + opts?: { values?: CfValues }, +): void { + const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); + const perUnit = lg.resultUnit.split("/").slice(1).join("/"); + const perText = !perUnit + ? ts("Total") + : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); + const { cost } = result; + const big = el("p", { + className: "m02-dfsum__big", + children: [ + el("span", { text: money ? perText : ts("Total") }), + el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }), + ], + }); + const costs = el("dl", { + className: "m02-dfsum__costs", + children: COSTS.flatMap((c) => [ + el("dt", { text: ts(COST_KEY[c]) }), + el("dd", { text: formatMoney(cost[c]) }), + ]), + }); + + /* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */ + const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); + const costed = outs.filter((b) => b.output?.cost !== "값"); + const plain = outs.filter((b) => b.output?.cost === "값"); + const line = (b: CfBox, isCost: boolean): HTMLElement => + el("tr", { + children: [ + el("td", { text: b.name }), + ...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []), + el("td", { + className: "m02-dfsum__num", + text: showValue(result.outputs[b.id], isCost), + }), + el("td", { text: b.output?.unit ?? b.unit }), + ], + }); + const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement => + el("div", { + className: "m02-dfsum__block", + children: [ + el("strong", { text: title }), + el("div", { + className: "m02-dfsum__scroll", + children: [ + el("table", { + className: "m02-dfsum__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + el("th", { text: ts("HName") }), + ...(isCost ? [el("th", { text: ts("HCost") })] : []), + el("th", { text: ts("HValue") }), + el("th", { text: ts("HUnit") }), + ], + }), + ], + }), + el("tbody", { children: list.map((b) => line(b, isCost)) }), + ], + }), + ], + }), + ], + }); + + /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ + let checked: string[] = []; + try { + checked = checkLogic(lg, opts?.values ?? {}); + } catch { + checked = []; + } + const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])]; + + /* 예시값 — 변수 상자의 변수마다 */ + const msg = el("p", { className: "m02-dfsum__msg" }); + const fields = lg.boxes + .filter((b) => b.kind === "input" && b.input?.mode === "variable") + .flatMap((b) => + (b.input?.variables ?? []).map((v, i) => + sampleField( + v, + (value) => { + msg.textContent = ""; + if (!on) return; + try { + on.change({ + ...lg, + boxes: lg.boxes.map((x) => + x.id === b.id && x.input + ? { + ...x, + input: { + ...x.input, + variables: x.input.variables.map((y, j) => + j === i ? { ...y, example: value } : y, + ), + }, + } + : x, + ), + }); + } catch { + msg.textContent = ts("Cant"); + } + }, + msg, + ), + ), + ); + + const plainValues = Object.entries(cost.값들); + host.replaceChildren( + big, + costs, + ...(costed.length ? [table(costed, true, ts("Lines"))] : []), + ...(plain.length + ? [table(plain, false, ts("Plain"))] + : plainValues.length + ? [ + el("div", { + className: "m02-dfsum__block", + children: [ + el("strong", { text: ts("Plain") }), + ...plainValues.map(([k, v]) => + el("p", { className: "m02-dfsum__muted", text: `${k} · ${showValue(v)}` }), + ), + ], + }), + ] + : []), + ...(fields.length + ? [ + el("div", { + className: "m02-dfsum__strip", + children: [ + el("strong", { text: ts("Sample") }), + el("div", { className: "m02-dfsum__fields", children: fields }), + el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }), + msg, + ], + }), + ] + : []), + ...(problems.length + ? [ + el("div", { + className: "m02-dfsum__problems", + children: [ + el("strong", { text: ts("Problems") }), + el("ul", { children: problems.map((t) => el("li", { text: t })) }), + ], + }), + ] + : []), + ); +} diff --git a/M02_MasterTemplete/DataFlow/_sample/masonry.json b/M02_MasterTemplete/DataFlow/_sample/masonry.json new file mode 100644 index 000000000..7ad4f5adb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_sample/masonry.json @@ -0,0 +1,785 @@ +{ + "key": "SAMPLE-masonry", + "name": "견본 · 돌쌓기 메쌓기(인력)", + "resultUnit": "원/㎡", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "쌓기 조건", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 40, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "뒷길이", + "type": "choice", + "unit": "㎝", + "example": 30, + "choices": [25, 30, 35, 45, 55, 60, 75] + }, + { + "name": "돌", + "type": "choice", + "unit": "-", + "example": "깬돌", + "choices": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] + }, + { + "name": "쌓기", + "type": "choice", + "unit": "-", + "example": "골쌓기", + "choices": ["골쌓기", "켜쌓기"] + }, + { + "name": "높이", + "type": "number", + "unit": "m", + "example": 4.5 + } + ] + } + }, + { + "id": "in-poom", + "kind": "input", + "name": "메쌓기 품표", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 300, + "input": { + "mode": "group", + "group": "소요량", + "elements": [], + "table": { + "key": "QF000421" + }, + "variables": [] + } + }, + { + "id": "in-rate", + "kind": "input", + "name": "높이 증가율표", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 460, + "input": { + "mode": "group", + "group": "소요량", + "elements": [], + "table": { + "key": "QF000422" + }, + "variables": [] + } + }, + { + "id": "in-labor", + "kind": "input", + "name": "인력", + "color": "#2f80ed", + "unit": "원/일", + "x": 40, + "y": 620, + "input": { + "mode": "group", + "group": "인력", + "variables": [], + "elements": [ + { + "ref": "LB000033", + "name": "석공", + "spec": "", + "unit": "원/일" + }, + { + "ref": "LB000002", + "name": "보통인부", + "spec": "", + "unit": "원/일" + } + ] + } + }, + { + "id": "fn-rate", + "kind": "function", + "name": "증가율 찾기", + "color": "#7b4fd6", + "unit": "%", + "x": 340, + "y": 420, + "func": { + "sheet": { + "id": "cf-rate", + "이름": "증가율 찾기", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "높이" + }, + "C2": { + "값": "받는 값 · m" + }, + "A3": { + "값": "표에서 찾은 증가율" + }, + "B3": { + "식": "SUMIFS($G$2:$G$100,$E$2:$E$100,\"<\"&B2,$F$2:$F$100,\">=\"&B2)" + }, + "C3": { + "값": "높이가 「아래 < 높이 ≤ 위」 인 줄" + }, + "A4": { + "값": "증가율" + }, + "B4": { + "식": "IF(B2<=3,0,B3)" + }, + "C4": { + "값": "3m 까지는 할증 없음" + } + } + }, + "inputs": [ + { + "port": "높이", + "cell": "B2" + }, + { + "port": "증가율표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "증가율", + "unit": "%" + } + } + }, + { + "id": "fn-poom0", + "kind": "function", + "name": "석공 품", + "color": "#7b4fd6", + "unit": "인/㎡", + "x": 640, + "y": 80, + "func": { + "sheet": { + "id": "cf-poom0", + "이름": "석공 품", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "뒷길이" + }, + "C2": { + "값": "받는 값 · ㎝" + }, + "A3": { + "값": "돌" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "쌓기" + }, + "C4": { + "값": "받는 값" + }, + "A5": { + "값": "증가율" + }, + "C5": { + "값": "받는 값 · %" + }, + "A6": { + "값": "표에서 찾은 품" + }, + "B6": { + "식": "SUMIFS($H$2:$H$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" + }, + "C6": { + "값": "세 조건이 맞는 줄의 품" + }, + "A7": { + "값": "석공 품" + }, + "B7": { + "식": "B6*(1+B5/100)" + }, + "C7": { + "값": "높이 할증을 얹음" + } + } + }, + "inputs": [ + { + "port": "뒷길이", + "cell": "B2" + }, + { + "port": "돌", + "cell": "B3" + }, + { + "port": "쌓기", + "cell": "B4" + }, + { + "port": "증가율", + "cell": "B5" + }, + { + "port": "품표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B7", + "name": "석공 품", + "unit": "인/㎡" + } + } + }, + { + "id": "fn-poom1", + "kind": "function", + "name": "보통인부 품", + "color": "#7b4fd6", + "unit": "인/㎡", + "x": 640, + "y": 300, + "func": { + "sheet": { + "id": "cf-poom1", + "이름": "보통인부 품", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "뒷길이" + }, + "C2": { + "값": "받는 값 · ㎝" + }, + "A3": { + "값": "돌" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "쌓기" + }, + "C4": { + "값": "받는 값" + }, + "A5": { + "값": "증가율" + }, + "C5": { + "값": "받는 값 · %" + }, + "A6": { + "값": "표에서 찾은 품" + }, + "B6": { + "식": "SUMIFS($I$2:$I$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" + }, + "C6": { + "값": "세 조건이 맞는 줄의 품" + }, + "A7": { + "값": "보통인부 품" + }, + "B7": { + "식": "B6*(1+B5/100)" + }, + "C7": { + "값": "높이 할증을 얹음" + } + } + }, + "inputs": [ + { + "port": "뒷길이", + "cell": "B2" + }, + { + "port": "돌", + "cell": "B3" + }, + { + "port": "쌓기", + "cell": "B4" + }, + { + "port": "증가율", + "cell": "B5" + }, + { + "port": "품표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B7", + "name": "보통인부 품", + "unit": "인/㎡" + } + } + }, + { + "id": "fn-pay0", + "kind": "function", + "name": "석공 노무비", + "color": "#7b4fd6", + "unit": "원/㎡", + "x": 940, + "y": 80, + "func": { + "sheet": { + "id": "cf-pay0", + "이름": "석공 노무비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "석공 품" + }, + "C2": { + "값": "받는 값 · 인/㎡" + }, + "A3": { + "값": "석공 노임" + }, + "C3": { + "값": "받는 값 · 원/인" + }, + "A4": { + "값": "석공 노무비" + }, + "B4": { + "식": "B2*B3" + }, + "C4": { + "값": "품 × 노임" + } + } + }, + "inputs": [ + { + "port": "석공 품", + "cell": "B2" + }, + { + "port": "석공 노임", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "석공 노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "fn-pay1", + "kind": "function", + "name": "보통인부 노무비", + "color": "#7b4fd6", + "unit": "원/㎡", + "x": 940, + "y": 300, + "func": { + "sheet": { + "id": "cf-pay1", + "이름": "보통인부 노무비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "보통인부 품" + }, + "C2": { + "값": "받는 값 · 인/㎡" + }, + "A3": { + "값": "보통인부 노임" + }, + "C3": { + "값": "받는 값 · 원/인" + }, + "A4": { + "값": "보통인부 노무비" + }, + "B4": { + "식": "B2*B3" + }, + "C4": { + "값": "품 × 노임" + } + } + }, + "inputs": [ + { + "port": "보통인부 품", + "cell": "B2" + }, + { + "port": "보통인부 노임", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "보통인부 노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "fn-sum", + "kind": "function", + "name": "노무비 합", + "color": "#7b4fd6", + "unit": "원/㎡", + "x": 1240, + "y": 190, + "func": { + "sheet": { + "id": "cf-sum", + "이름": "노무비 합", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "석공 노무비" + }, + "C2": { + "값": "받는 값" + }, + "A3": { + "값": "보통인부 노무비" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "노무비" + }, + "B4": { + "식": "B2+B3" + }, + "C4": { + "값": "둘을 더함" + } + } + }, + "inputs": [ + { + "port": "석공 노무비", + "cell": "B2" + }, + { + "port": "보통인부 노무비", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "out-pay", + "kind": "output", + "name": "노무비", + "color": "#27ae60", + "unit": "원/㎡", + "x": 1520, + "y": 190, + "output": { + "cost": "노무비", + "unit": "원/㎡" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "높이" + }, + "to": { + "box": "fn-rate", + "name": "높이" + } + }, + { + "id": "l2", + "from": { + "box": "in-rate", + "name": "표" + }, + "to": { + "box": "fn-rate", + "name": "증가율표" + } + }, + { + "id": "l3", + "from": { + "box": "in-var", + "name": "뒷길이" + }, + "to": { + "box": "fn-poom0", + "name": "뒷길이" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "돌" + }, + "to": { + "box": "fn-poom0", + "name": "돌" + } + }, + { + "id": "l5", + "from": { + "box": "in-var", + "name": "쌓기" + }, + "to": { + "box": "fn-poom0", + "name": "쌓기" + } + }, + { + "id": "l6", + "from": { + "box": "fn-rate", + "name": "증가율" + }, + "to": { + "box": "fn-poom0", + "name": "증가율" + } + }, + { + "id": "l7", + "from": { + "box": "in-poom", + "name": "표" + }, + "to": { + "box": "fn-poom0", + "name": "품표" + } + }, + { + "id": "l8", + "from": { + "box": "in-var", + "name": "뒷길이" + }, + "to": { + "box": "fn-poom1", + "name": "뒷길이" + } + }, + { + "id": "l9", + "from": { + "box": "in-var", + "name": "돌" + }, + "to": { + "box": "fn-poom1", + "name": "돌" + } + }, + { + "id": "l10", + "from": { + "box": "in-var", + "name": "쌓기" + }, + "to": { + "box": "fn-poom1", + "name": "쌓기" + } + }, + { + "id": "l11", + "from": { + "box": "fn-rate", + "name": "증가율" + }, + "to": { + "box": "fn-poom1", + "name": "증가율" + } + }, + { + "id": "l12", + "from": { + "box": "in-poom", + "name": "표" + }, + "to": { + "box": "fn-poom1", + "name": "품표" + } + }, + { + "id": "l13", + "from": { + "box": "fn-poom0", + "name": "석공 품" + }, + "to": { + "box": "fn-pay0", + "name": "석공 품" + } + }, + { + "id": "l14", + "from": { + "box": "in-labor", + "name": "석공" + }, + "to": { + "box": "fn-pay0", + "name": "석공 노임" + } + }, + { + "id": "l15", + "from": { + "box": "fn-poom1", + "name": "보통인부 품" + }, + "to": { + "box": "fn-pay1", + "name": "보통인부 품" + } + }, + { + "id": "l16", + "from": { + "box": "in-labor", + "name": "보통인부" + }, + "to": { + "box": "fn-pay1", + "name": "보통인부 노임" + } + }, + { + "id": "l17", + "from": { + "box": "fn-pay0", + "name": "석공 노무비" + }, + "to": { + "box": "fn-sum", + "name": "석공 노무비" + } + }, + { + "id": "l18", + "from": { + "box": "fn-pay1", + "name": "보통인부 노무비" + }, + "to": { + "box": "fn-sum", + "name": "보통인부 노무비" + } + }, + { + "id": "l19", + "from": { + "box": "fn-sum", + "name": "노무비" + }, + "to": { + "box": "out-pay", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/DataFlow/_sample/rate.json b/M02_MasterTemplete/DataFlow/_sample/rate.json new file mode 100644 index 000000000..cf1ff8428 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_sample/rate.json @@ -0,0 +1,146 @@ +{ + "key": "SAMPLE-rate", + "name": "견본 · 공구손료(비율)", + "resultUnit": "원", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "바탕 값", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 80, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "노무비", + "type": "number", + "unit": "원", + "example": 100000 + }, + { + "name": "공구손료율", + "type": "number", + "unit": "%", + "example": 3 + } + ] + } + }, + { + "id": "fn-rate", + "kind": "function", + "name": "공구손료", + "color": "#7b4fd6", + "unit": "원", + "x": 340, + "y": 80, + "func": { + "sheet": { + "id": "cf-tool", + "이름": "공구손료", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "노무비" + }, + "C2": { + "값": "받는 값 · 원" + }, + "A3": { + "값": "공구손료율" + }, + "C3": { + "값": "받는 값 · %" + }, + "A4": { + "값": "공구손료" + }, + "B4": { + "식": "B2*B3/100" + }, + "C4": { + "값": "노무비 × 율 ÷ 100" + } + } + }, + "inputs": [ + { + "port": "노무비", + "cell": "B2" + }, + { + "port": "공구손료율", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "공구손료", + "unit": "원" + } + } + }, + { + "id": "out-mat", + "kind": "output", + "name": "재료비", + "color": "#27ae60", + "unit": "원", + "x": 640, + "y": 80, + "output": { + "cost": "재료비", + "unit": "원" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "노무비" + }, + "to": { + "box": "fn-rate", + "name": "노무비" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "공구손료율" + }, + "to": { + "box": "fn-rate", + "name": "공구손료율" + } + }, + { + "id": "l3", + "from": { + "box": "fn-rate", + "name": "공구손료" + }, + "to": { + "box": "out-mat", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/DataFlow/_sample/value.json b/M02_MasterTemplete/DataFlow/_sample/value.json new file mode 100644 index 000000000..c292ba947 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_sample/value.json @@ -0,0 +1,200 @@ +{ + "key": "SAMPLE-value", + "name": "견본 · 덤프트럭 시간당 작업량(값)", + "resultUnit": "㎥/시간", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "덤프트럭 조건", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 80, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "q", + "type": "number", + "unit": "㎥", + "example": 6 + }, + { + "name": "f", + "type": "number", + "unit": "-", + "example": 1.2 + }, + { + "name": "E", + "type": "number", + "unit": "-", + "example": 0.9 + }, + { + "name": "Cm", + "type": "number", + "unit": "분", + "example": 25 + } + ] + } + }, + { + "id": "fn-q", + "kind": "function", + "name": "시간당 작업량", + "color": "#7b4fd6", + "unit": "㎥/시간", + "x": 340, + "y": 80, + "func": { + "sheet": { + "id": "cf-q", + "이름": "시간당 작업량", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "q" + }, + "C2": { + "값": "적재량 · ㎥" + }, + "A3": { + "값": "f" + }, + "C3": { + "값": "토량환산계수" + }, + "A4": { + "값": "E" + }, + "C4": { + "값": "작업효율" + }, + "A5": { + "값": "Cm" + }, + "C5": { + "값": "1회 사이클 시간 · 분" + }, + "A6": { + "값": "시간당 작업량" + }, + "B6": { + "식": "ROUND(60*B2*B3*B4/B5,3)" + }, + "C6": { + "값": "60 × q × f × E ÷ Cm · 소수 셋째 자리" + } + } + }, + "inputs": [ + { + "port": "q", + "cell": "B2" + }, + { + "port": "f", + "cell": "B3" + }, + { + "port": "E", + "cell": "B4" + }, + { + "port": "Cm", + "cell": "B5" + } + ], + "variables": [], + "output": { + "cell": "B6", + "name": "시간당 작업량", + "unit": "㎥/시간" + } + } + }, + { + "id": "out-q", + "kind": "output", + "name": "시간당 작업량", + "color": "#27ae60", + "unit": "㎥/시간", + "x": 640, + "y": 80, + "output": { + "cost": "값", + "unit": "㎥/시간" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "q" + }, + "to": { + "box": "fn-q", + "name": "q" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "f" + }, + "to": { + "box": "fn-q", + "name": "f" + } + }, + { + "id": "l3", + "from": { + "box": "in-var", + "name": "E" + }, + "to": { + "box": "fn-q", + "name": "E" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "Cm" + }, + "to": { + "box": "fn-q", + "name": "Cm" + } + }, + { + "id": "l5", + "from": { + "box": "fn-q", + "name": "시간당 작업량" + }, + "to": { + "box": "out-q", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/resources/tester/test_m02_df_inputpanel_build.py b/resources/tester/test_m02_df_inputpanel_build.py new file mode 100644 index 000000000..b37fdd1c7 --- /dev/null +++ b/resources/tester/test_m02_df_inputpanel_build.py @@ -0,0 +1,254 @@ +"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`M02_DF_InputPanel_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 줄 · 상자는 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +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, () => ({})); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((j) => { + if (j.op === "fn") return m[j.name](...j.args); + let lg = j.lg; + for (const [fn, ...args] of j.steps || []) lg = m[fn](lg, ...args); + return lg; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def logic() -> dict: + plain = {"color": "#fff", "unit": "-", "x": 0, "y": 0} + return { + "key": None, + "name": "견본", + "resultUnit": "원/㎡", + "userElements": [], + "boxes": [ + { + "id": "b1", + "kind": "input", + "name": "입력", + **plain, + "input": {"mode": "variable", "elements": [], "variables": []}, + }, + {"id": "b2", "kind": "function", "name": "함수", **plain}, + ], + "links": [], + } + + +def call(name: str, *args): + return run([{"op": "fn", "name": name, "args": list(args)}])[0] + + +def edit(steps: list, lg: dict | None = None) -> dict: + return run([{"op": "lg", "lg": lg or logic(), "steps": steps}])[0] + + +LABOR = {"키": "LB000033", "이름": "석공", "규격": "", "단위": "원/일", "값": 268908} +LABOR2 = {"키": "LB000002", "이름": "보통인부", "규격": "", "단위": "원/일", "값": 172068} +MAT = { + "키": "MT1", + "이름": "각형흄관", + "규격": "D=300mm", + "단위": "M", + "물가자료": 30000, + "물가정보": 29600, +} +MACHINE = { + "키": "EQ000001", + "이름": "불도저", + "규격": "7ton", + "단위": {"가격": "천원", "손료계수": "10^-7/시간"}, + "값": {"가격": 76547, "손료계수": 1811}, +} +RATE_ONE = {"키": "RT1", "이름": "산재보험료", "값": {"rate_percent": 3.56}} +RATE_BRACKET = {"키": "RT2", "이름": "고용보험료", "값": {"brackets": []}} + + +def test_요소는_이름과_규격으로_알약_이름이_됨(): + labor = call("elementOf", "인력", LABOR) + assert labor == {"ref": "LB000033", "name": "석공", "spec": "", "unit": "원/일"} + got = call("elementOf", "재료", MAT) + assert got["name"] == "각형흄관 D=300mm" and got["unit"] == "M" + + +def test_기계는_값_칸을_골라_요소가_됨(): + assert call("machineFields", MACHINE) == ["가격", "손료계수"] + got = call("elementOf", "기계", MACHINE, "손료계수") + assert got["ref"] == "EQ000001#손료계수" and got["unit"] == "10^-7/시간" + assert got["name"].endswith("· 손료계수") + assert call("queryOf", "기계", "EQ000001#손료계수") == { + "group": "기계", + "ref": "EQ000001", + "field": "손료계수", + } + + +def test_값_읽기_재료는_낮은_값_요율은_비율(): + assert call("valueOf", "재료", MAT) == 29600 + assert call("valueOf", "요율", RATE_ONE) == 3.56 + assert call("valueOf", "요율", RATE_BRACKET) is None + assert call("elementOf", "요율", RATE_ONE)["unit"] == "%" + assert call("elementOf", "요율", RATE_BRACKET)["unit"] == "-" + assert call("valueOf", "기계", MACHINE, "가격") == 76547 + + +def test_사용자_요소는_그_그룹의_ref_로_물음(): + assert call("queryOf", "인력", "UE-0001") == {"group": "인력", "ref": "UE-0001"} + assert call("queryOf", "재료", "MT1") == {"group": "재료", "ref": "MT1"} + row = {"키": "UE-0001", "이름": "특수공", "규격": "", "단위": "원/일", "값": 300000} + assert call("elementOf", "인력", row)["unit"] == "원/일" + assert call("elementOf", "요율", {**row, "단위": "%"})["unit"] == "%" + + +def test_상자_단위는_같으면_그_단위_여럿이면_하이픈(): + one = edit([["setKind", "b1", "인력"], ["addElement", "b1", call("elementOf", "인력", LABOR)]]) + assert one["boxes"][0]["unit"] == "원/일" + two = edit([["addElement", "b1", call("elementOf", "인력", LABOR2)]], one) + assert two["boxes"][0]["unit"] == "원/일" and len(two["boxes"][0]["input"]["elements"]) == 2 + oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} + assert edit([["addElement", "b1", oil]], two)["boxes"][0]["unit"] == "-" + + +def test_같은_요소_이름은_한_번만(): + el = call("elementOf", "인력", LABOR) + lg = edit( + [ + ["setKind", "b1", "인력"], + ["addElement", "b1", el], + ["addElement", "b1", el], + ["addElement", "b1", {**el, "ref": "다른"}], + ] + ) + assert len(lg["boxes"][0]["input"]["elements"]) == 1 + + +def test_요소를_빼면_그_알약에서_나간_선도_지움(): + el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) + base = edit([["setKind", "b1", "인력"], ["addElement", "b1", el1], ["addElement", "b1", el2]]) + base["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "석공"}, "to": {"box": "b2", "name": "a"}}, + {"id": "l2", "from": {"box": "b1", "name": "보통인부"}, "to": {"box": "b2", "name": "b"}}, + ] + out = edit([["removeElement", "b1", "LB000033"]], base) + assert [x["id"] for x in out["links"]] == ["l2"] + assert [e["name"] for e in out["boxes"][0]["input"]["elements"]] == ["보통인부"] + + +def test_갈래를_바꾸면_고른_것을_비움_표는_요소와_함께_못_둠(): + el = call("elementOf", "인력", LABOR) + lg = edit([["setKind", "b1", "인력"], ["addElement", "b1", el], ["setKind", "b1", "재료"]]) + assert lg["boxes"][0]["input"]["group"] == "재료" + assert lg["boxes"][0]["input"]["elements"] == [] + t = edit([["setKind", "b1", "소요량"], ["setTable", "b1", {"key": "QF000421"}, "메쌓기(인력)"]]) + assert t["boxes"][0]["input"]["table"] == {"key": "QF000421"} + assert t["boxes"][0]["name"] == "메쌓기(인력)" and t["boxes"][0]["unit"] == "-" + row = edit([["setTable", "b1", {"key": "QF000421", "row": 3}]], t) + assert row["boxes"][0]["input"]["table"] == {"key": "QF000421", "row": 3} + assert call("pillNames", row["boxes"][0]) == ["표"] + + +def test_변수_넣기_고치기_삭제_알약_이름(): + v = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} + lg = edit([["setKind", "b1", "변수"], ["putVariable", "b1", v]]) + box = lg["boxes"][0] + assert box["input"]["mode"] == "variable" and box["unit"] == "㎡" + assert call("pillNames", box) == ["면적"] + renamed = edit([["putVariable", "b1", {**v, "name": "연장", "unit": "m"}, "면적"]], lg) + assert call("pillNames", renamed["boxes"][0]) == ["연장"] and renamed["boxes"][0]["unit"] == "m" + gone = edit([["removeVariable", "b1", "연장"]], renamed) + assert gone["boxes"][0]["input"]["variables"] == [] and gone["boxes"][0]["unit"] == "-" + + +def test_변수_검사는_쉬운_말(): + ok = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} + assert call("checkVariable", ok, []) is None + assert call("checkVariable", {**ok, "name": " "}, []) == "이름을 적어 주세요" + assert call("checkVariable", ok, [ok]) == "같은 이름의 변수가 이미 있음" + assert call("checkVariable", {**ok, "range": [9, 1]}, []) == "범위는 아래가 위보다 작아야 함" + assert call("checkVariable", {**ok, "example": 5000}, []) == "예시값이 범위 밖" + many = {**ok, "type": "choice", "choices": ["가"]} + assert call("checkVariable", many, []) == "선택 목록은 둘 이상" + bad = {**ok, "type": "choice", "choices": ["가", "나"], "example": "다"} + assert call("checkVariable", bad, []) == "예시값이 선택 목록에 없음" + + +def test_환율_유가전력_알약_이름에_날짜가_안_들어감(): + fx = {"키": "FX1", "이름": "USD", "규격": "2026-08-14", "단위": "원/USD", "값": 1414.9} + assert call("elementOf", "환율", fx)["name"] == "USD" + oil = { + "키": "MO1", + "이름": "휘발유", + "규격": "2026-08-14", + "상세구분": "서울", + "단위": "원/L", + "값": 1900, + } + assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울" + + +def test_단위가_안_맞게_저장된_상자는_열_때_맞춤(): + el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) + lg = logic() + lg["boxes"][0]["input"] = { + "mode": "group", + "group": "인력", + "elements": [el1, el2], + "variables": [], + } + assert lg["boxes"][0]["unit"] == "-" + fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0] + assert fixed["boxes"][0]["unit"] == "원/일" + again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0] + assert again == fixed + oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} + lg["boxes"][0]["input"]["elements"].append(oil) + lg["boxes"][0]["unit"] = "원/일" + assert ( + run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] + == "-" + ) + one = { + "mode": "variable", + "elements": [], + "variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}], + } + lg["boxes"][0]["input"] = one + assert ( + run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] + == "㎡" + ) diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py new file mode 100644 index 000000000..6081bd45a --- /dev/null +++ b/resources/tester/test_m02_df_model.py @@ -0,0 +1,348 @@ +"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_DF_Model.ts` · `M02_DF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. + +· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 +· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사 +· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길) +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CF = ROOT / "M02_MasterTemplete" / "DataFlow" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +LOADER = r""" +const fs = require("fs"); +const path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const mod = { exports: {} }; + cache.set(full, mod); + const req = (r) => { + if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; + if (r.endsWith(".css")) return {}; + if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); + if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`)); + return require(r); + }; + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Model.ts")); +const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Eval.ts")); +const out = (x) => console.log(JSON.stringify(x)); +const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y); +const join = (lg, a, ap, b, bp) => { + const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }); + if (r.why) throw new Error(r.why); + return r.lg; +}; +/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */ +const func = (lg, box, cells, inputs, output, variables = []) => + M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables, + output: { ...box.func.output, ...output } } }); +""" + + +def _run(body: str) -> object: + result = subprocess.run( + ["node", "-e", LOADER + body, str(ROOT)], + capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비 +ONE = r""" +let lg = M.newLogic("견본 하나", "원/㎡"); +let r = add(lg, "input"); lg = r.lg; let man = r.box; +lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력", + elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } }); +r = add(lg, "input"); lg = r.lg; let area = r.box; +lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [], + variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } }); +r = add(lg, "function"); lg = r.lg; let f = r.box; +lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } }, + [{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" }, + [{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]); +r = add(lg, "output"); lg = r.lg; let o = r.box; +lg = join(lg, man, "석공", f, "노임"); +lg = join(lg, area, "면적", f, "면적"); +lg = join(lg, f, "노무비", o, "값"); +const V = { [man.id]: { 석공: 268908 } }; +""" + +# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬) +TWO = r""" +let lg = M.newLogic("견본 둘"); +let r = add(lg, "input"); lg = r.lg; const inp = r.box; +lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력", + elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }], + variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } }); +const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box; + lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; }; +const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]); +const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]); +const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]); +const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]); +lg = join(lg, inp, "면적", f1, "가"); +lg = join(lg, inp, "보통인부", f2, "가"); +lg = join(lg, f1, "결과", f3, "가"); +lg = join(lg, f2, "결과", f3, "나"); +lg = join(lg, f3, "결과", f4, "가"); +r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } }); +r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } }); +r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } }); +lg = join(lg, f4, "결과", oExp, "값"); +lg = join(lg, f1, "결과", oVal, "값"); +lg = join(lg, inp, "면적", oMat, "값"); +const V = { [inp.id]: { 보통인부: 172068 } }; +""" + + +def test_견본하나_입력_변수_함수_출력_노무비() -> None: + got = _run( + ONE + + "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);" + ) + ev, check, boxes = got + assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908 + assert ev["outputs"] == {"b4": 537816} + assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816} + assert ev["errors"] == [] and check == [] + assert boxes == [ + ["b1", "input", "입력", "#2f80ed", "-"], + ["b2", "input", "입력 2", "#2f80ed", "-"], + ["b3", "function", "함수", "#7b4fd6", "-"], + ["b4", "output", "출력", "#27ae60", "-"], + ] + + +def test_변수값_바꾸면_값이_바뀜() -> None: + got = _run( + ONE + + "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);" + ) + assert got == 0.1 * 20 * 268908 + + +def test_견본둘_직렬_병렬_원가_합() -> None: + got = _run( + TWO + + "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);" + ) + ports, cost, errors, check = got + f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5")) + assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938) + assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943} + assert errors == [] and check == [] + + +def test_선_규칙() -> None: + got = _run( + TWO + + "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n" + "out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적')," + " t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값')," + " M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);" + ) + assert got == [ + "이으면 돌고 도는 선이 됨", + "이으면 돌고 도는 선이 됨", + "출력 상자에서는 선이 나가지 않음", + "입력 상자로는 선이 들어가지 않음", + "같은 상자끼리는 못 이음", + "이미 선이 들어온 자리", + "알약이 없음", + "이미 선이 들어온 자리", + True, + ] + + +def test_알약_출력은_하나() -> None: + got = _run( + TWO + + "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n" + "out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3])," + " M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);" + ) + in_out, in_in, f_out, f_in, o_out, o_in = got + assert in_out == [ + {"name": "보통인부", "unit": "인"}, + {"name": "표", "unit": "-"}, + {"name": "면적", "unit": "㎡"}, + ] + assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}] + assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}] + assert o_out == [] and o_in == [{"name": "값", "unit": "원"}] + + +def test_선_끊기_상자_지우기_검사() -> None: + got = _run( + TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n" + "const a = M.unlink(lg, cut);\n" + "const b = M.removeBox(lg, f3.id);\n" + "const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n" + "out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length," + " E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);" + ) + f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got + assert f3_after == {"결과": 10} + assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"] + assert (n_b, n_all) == (5, 8) + assert check_b == [ + "「함수 4」 의 받는 알약 「가」 에 선이 없음", + ] + assert check_c == ["출력 상자가 없음"] + assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"] + + +def test_오류칸_순환칸_숫자아닌_출력() -> None: + got = _run( + ONE + + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," + " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n" + "const a = E.evalLogic(lg, V);\n" + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," + " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n" + "const b = E.evalLogic(lg, V);\n" + "out([a.errors, a.cost.계, b.errors]);" + ) + a_err, total, b_err = got + assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"] + assert total == 0 + assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err + + +def test_자리_색_단위() -> None: + got = _run( + ONE + + "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n" + "g = M.setUnit(g, o.id, ' ');\n" + "const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n" + "out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);" + ) + assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0] + + +def test_사용자_요소_값() -> None: + got = _run( + ONE + + "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n" + "lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n" + "out(E.evalLogic(lg, {}).cost.노무비);" + ) + assert got == 0.2 * 10 * 300000 + + +def test_화면_글에_금지_낱말_없음() -> None: + for name in ("M02_DF_Model.ts", "M02_DF_Eval.ts"): + src = (CF / name).read_text(encoding="utf-8") + block = src[src.index("const TEXT = {") : src.index("} as const;")] + korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] + assert korean + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): + assert bad not in text, (name, bad, text) + + +def test_알약_이름_규칙() -> None: + got = _run( + "let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n" + "lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료'," + " elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 }," + " variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n" + "out(M.portsOut(lg.boxes[0]).map(p => p.name));" + ) + assert got == ["시멘트", "표", "길이"] + + +def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None: + got = _run( + ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" + "let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n" + "let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n" + "let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n" + "let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n" + "const gb = (x) => x.boxes.find(y => y.id === g.id);\n" + "const inputs = gb(d.lg).func.inputs;\n" + "const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n" + "const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n" + "const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n" + "const removed = M.removeBox(d.lg, man.id);\n" + "out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to)," + " check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port)," + " M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);" + ) + a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got + assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None) + assert inputs == [ + {"port": "석공", "cell": ""}, + {"port": "면적", "cell": ""}, + {"port": "노무비", "cell": ""}, + ] + assert ports == ["석공", "면적", "노무비"] + assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}] + assert check == [ + "「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음", + "「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음", + "「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음", + ] + assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음 + assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움 + assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음 + + +def test_같은_이름_받는_알약은_번호() -> None: + got = _run( + ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" + "let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" + "x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n" + "r = add(x, 'input'); x = r.lg; const m2 = r.box;\n" + "x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n" + "x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" + "x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n" + "out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));" + ) + assert got == ["석공", "노무비", "석공 (2)", "할증"] + + +def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None: + got = _run( + "let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n" + "r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n" + "r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n" + "lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n" + "lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" + "lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n" + "r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n" + "lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n" + "lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" + "out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값])," + " lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);" + ) + names, ports = got + assert names == [ + ["결과 1", "결과 1"], + ["결과 3", "결과 3"], + ["결과 2", "결과 2"], + ["결과 4", "결과 4"], + ] + assert ports == ["결과 1", "결과 2", "결과 1 (2)"] diff --git a/resources/tester/test_m02_df_router.py b/resources/tester/test_m02_df_router.py new file mode 100644 index 000000000..4b13147c9 --- /dev/null +++ b/resources/tester/test_m02_df_router.py @@ -0,0 +1,353 @@ +"""단가산출 다이어그램 시험 길(`M02_MasterTemplete/DataFlow/M02_DF_Router.py`) 시험 — PLAN 35-7. + +· Model CfLogic(boxes · links · userElements) 저장 → 열기 → 목록 → 지우기 · 견본(`_sample`)은 읽기만 +· 저장 때 입력 값을 마스터에서 다시 받고 함수 상자 시트를 한 통합문서로 Node 셈 → + 받는 칸에 값만 되돌림 + (입력 → 함수 · 함수 → 함수 직렬 · 표 블록 · 변수 · 출력 상자 비용 종류 · 원가) +· 입력 값 — 인력 · 재료(키 · 검색어 묶음) · 유가전력 · 환율 · 요율 · 기계 값 칸 · 표 블록 · + 한 줄 · 사용자 요소 +· 사용자 요소 만들기 · 지우기 · 권한 없음 403 · 마스터 사본 파일은 안 바뀜 +견본 표 · 다이어그램은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 값에 안 기댐). +""" + +from __future__ import annotations + +import json +import shutil +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import require_system_admin, verify_session +from M01_MasterData import M01_MasterData_Router as m01_router +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete.DataFlow import M02_DF_Router as cf + +REAL = store.FOLDER +OWN = "로직_자체.json" +SAMPLE = "QF999995" +TABLE = { + "키": SAMPLE, + "원문번호": "", + "구분": "", + "상세구분": "", + "이름": "견본 높이 표", + "기준": "1㎡", + "출처": "자체", + "조건": {"돌": "고르기", "높이": "범위"}, + "범위규칙": "초과·이하", + "값칸": {"석공": "인", "증가율": "%"}, + "줄": [ + {"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]}, + {"돌": "견치돌", "높이": 4, "석공": 0.7}, + ], + "주": [], +} +HEAD = ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] +NO_NODE = shutil.which("node") is None + + +def _first(name: str, pred) -> dict: + rows = json.loads((store.FOLDER / name).read_text(encoding="utf-8"))["줄"] + return next(r for r in rows if pred(r)) + + +def _labor() -> tuple[str, Decimal]: + row = _first( + "인력.json", + lambda r: ( + r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float)) + ), + ) + return row["키"], Decimal(str(row["값"])) + + +def _box(id_: str, kind: str, name: str, **part) -> dict: + return {"id": id_, "kind": kind, "name": name, "color": "", "unit": "-", "x": 0, "y": 0, **part} + + +def _func(sheet: dict, inputs: list, output: dict, variables: list | None = None) -> dict: + return {"sheet": sheet, "inputs": inputs, "variables": variables or [], "output": output} + + +def _link(i: str, a: str, pa: str, b: str, pb: str) -> dict: + return {"id": i, "from": {"box": a, "name": pa}, "to": {"box": b, "name": pb}} + + +def _logic(labor: str, key: str | None = None) -> dict: + """노임 × 2 × 면적 × (1 + 할증%) = 노무비 → +1 = 경비(함수 → 함수) · 표 블록 E1 → H3 = 품(값) + · 브라우저가 보낸 받는 칸 값(999)은 엉터리.""" + s1 = {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 999}, "B1": {"식": "A1*2*A2*(1+A3/100)"}}} + group = {"mode": "group", "elements": [], "variables": []} + return { + "key": key, + "name": "견본 다이어그램", + "resultUnit": "원/㎡", + "boxes": [ + _box("in1", "input", "인력", input={**group, "group": "인력", + "elements": [{"ref": labor, "name": "석공", "spec": "", "unit": "인"}]}), + _box("in2", "input", "표", + input={**group, "group": "소요량", "table": {"key": SAMPLE}}), + _box("in3", "input", "면적", input={"mode": "variable", "elements": [], "variables": [ + {"name": "면적", "type": "number", "unit": "㎡", "example": 3}]}), + _box("f1", "function", "노무 셈", func=_func( + s1, [{"port": "노임", "cell": "A1"}, {"port": "면적", "cell": "A2"}], + {"cell": "B1", "name": "노무비", "unit": "원"}, + [{"name": "할증", "type": "number", "unit": "%", "example": 10, "cell": "A3"}])), + _box("f2", "function", "더하기", func=_func( + {"id": "s2", "이름": "더하기", "칸": {"B1": {"식": "A1+1"}}}, + [{"port": "앞", "cell": "A1"}], {"cell": "B1", "name": "합", "unit": "원"})), + _box("f3", "function", "표 찾기", func=_func( + {"id": "s3", "이름": "표 찾기", "칸": {"A1": {"식": "H3"}}}, + [{"port": "표", "cell": "E1"}], {"cell": "A1", "name": "품", "unit": "인"})), + _box("o1", "output", "노무비", output={"cost": "노무비", "unit": "원"}), + _box("o2", "output", "경비", output={"cost": "경비", "unit": "원"}), + _box("o3", "output", "품", output={"cost": "값", "unit": "인"}), + ], + "links": [ + _link("l1", "in1", "석공", "f1", "노임"), + _link("l2", "in3", "면적", "f1", "면적"), + _link("l3", "f1", "노무비", "f2", "앞"), + _link("l4", "in2", "표", "f3", "표"), + _link("l5", "f1", "노무비", "o1", "값"), + _link("l6", "f2", "합", "o2", "값"), + _link("l7", "f3", "품", "o3", "값"), + ], + "userElements": [], + } # fmt: skip + + +@pytest.fixture +def folder(tmp_path: Path, monkeypatch) -> Path: + master = tmp_path / "master" + master.mkdir() + for path in REAL.glob("*.json"): + if path.name == OWN: + continue + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, master / path.name) + host = sorted(master.glob("소요량_*.json"))[0] + data = json.loads(host.read_text(encoding="utf-8")) + data["표"].append(TABLE) + host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + sample = tmp_path / "_sample" + sample.mkdir() + one = {"key": None, "name": "견본 돌쌓기", "resultUnit": "원/㎡", "boxes": [], "links": [], + "userElements": []} # fmt: skip + (sample / "견본 돌쌓기.json").write_text(json.dumps(one, ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(store, "FOLDER", master) + monkeypatch.setattr(cf, "DATA", tmp_path / "_data") + monkeypatch.setattr(cf, "SAMPLE", sample) + return master + + +@pytest.fixture +def client(folder: Path) -> TestClient: + app = FastAPI() + app.include_router(m01_router.router) + app.include_router(cf.router) + return TestClient(app) + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _d(v) -> Decimal: + return Decimal(str(v)) + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_저장_서버가_다시_셈_값만_되돌림(client: TestClient, folder: Path) -> None: + labor, price = _labor() + before = {p.name: p.read_bytes() for p in folder.glob("*.json")} + + saved = _post(client, "/api/m02/df/save", {"logic": _logic(labor)}) + assert saved["key"] == "DF-0001" + v = saved["values"] + labor_cost = price * 2 * 3 * Decimal("1.1") + assert _d(v["ports"]["f1"]["노무비"]) == labor_cost + assert _d(v["ports"]["f2"]["합"]) == labor_cost + 1 # 함수 → 함수(직렬) + assert _d(v["ports"]["f3"]["품"]) == Decimal("0.7") # 표 블록 E1 부터 → H3 + assert _d(v["cost"]["노무비"]) == labor_cost and _d(v["cost"]["경비"]) == labor_cost + 1 + assert _d(v["cost"]["계"]) == 2 * labor_cost + 1 and _d(v["cost"]["값들"]["품"]) == Decimal( + "0.7" + ) + assert v["errors"] == [] + + boxes = {b["id"]: b for b in saved["logic"]["boxes"]} + c1 = boxes["f1"]["func"]["sheet"]["칸"] + assert c1["A1"]["값"] == price and c1["A2"]["값"] == 3 and c1["A3"]["값"] == 10 # 999 는 버림 + assert c1["B1"] == {"식": "A1*2*A2*(1+A3/100)"} and boxes["f1"]["func"]["sheet"]["id"] == "s1" + assert _d(boxes["f2"]["func"]["sheet"]["칸"]["A1"]["값"]) == labor_cost # 참조 아닌 값으로 + c3 = boxes["f3"]["func"]["sheet"]["칸"] + assert [c3[f"{c}1"]["값"] for c in "EFGHIJ"] == HEAD and c3["F3"]["값"] == c3["G3"]["값"] == 4 + assert saved["logic"]["links"] == _logic(labor)["links"] + + # 열기 · 목록 · 견본 · 고치기 · 지우기 + listed = client.get("/api/m02/df/list").json()["logics"] + assert [(x["key"], x["sample"]) for x in listed] == [("DF-0001", False), ("견본 돌쌓기", True)] + sample = client.get("/api/m02/df/get", params={"key": "견본 돌쌓기"}).json() + assert sample["sample"] is True + assert _post(client, "/api/m02/df/save", {"logic": sample["logic"]})["key"] == "DF-0002" + _post(client, "/api/m02/df/delete", {"key": "견본 돌쌓기"}, want=403) + renamed = {**saved["logic"], "name": "고친 이름"} + assert _post(client, "/api/m02/df/save", {"logic": renamed})["key"] == "DF-0001" + got = client.get("/api/m02/df/get", params={"key": "DF-0001"}).json()["logic"] + assert ( + got["name"] == "고친 이름" and got["boxes"][3]["func"]["sheet"]["칸"]["A1"]["값"] == price + ) + _post(client, "/api/m02/df/delete", {"key": "DF-0002"}) + assert client.get("/api/m02/df/get", params={"key": "DF-0002"}).status_code == 404 + _post(client, "/api/m02/df/save", {"logic": _logic(labor, "DF-0009")}, want=404) + _post(client, "/api/m02/df/save", {"logic": {**_logic(labor), "name": " "}}, want=400) + _post(client, "/api/m02/df/save", {"logic": {"name": "x"}}, want=400) + + assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로 + assert sorted(p.name for p in cf.DATA.glob("*.json")) == ["DF-0001.json"] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_빈_입력_값은_쉬운_말(client: TestClient) -> None: + labor, _ = _labor() + logic = _logic(labor) + logic["boxes"][0]["input"]["elements"][0]["ref"] = "LB999999" + v = _post(client, "/api/m02/df/save", {"logic": logic})["values"] + assert v["errors"] and "석공" in v["errors"][0] + + +def test_입력_값_그룹마다(client: TestClient) -> None: + labor, price = _labor() + mat = _first( + "재료_자재품목.json", + lambda r: isinstance(r.get("물가자료"), (int, float)) and r.get("구분"), + ) + word = f"{mat['이름']} {mat['규격'] or ''}".strip() + cond = {"구분": mat["구분"], "상세구분": mat["상세구분"], "검색어": word} + line = {"종류": "재료", "요소": cond, "이름": "재료", "단위": mat["단위"], "수량": "1", + "비목": "재료비"} # fmt: skip + row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체", + "입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip + want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"] + oil = _first("재료_유가전력.json", lambda r: isinstance(r.get("값"), (int, float))) + fx = _first("환율_한국은행환율.json", lambda r: isinstance(r.get("값"), (int, float))) + rate = _first("요율_조달청제비율.json", lambda r: isinstance(r.get("값"), dict)) + eq = _first("기계.json", lambda r: isinstance((r.get("값") or {}).get("가격"), (int, float))) + local = [{"id": "UE-0007", "group": "재료", "name": "현장 자재", "spec": "", "unit": "개", + "value": 500}] # fmt: skip + items = [ + {"id": "l", "group": "인력", "ref": labor}, + { + "id": "m", + "group": "재료", + "ref": json.dumps(cond, ensure_ascii=False), + "unit": mat["단위"], + }, + {"id": "o", "group": "유가전력", "ref": oil["키"]}, + {"id": "f", "group": "환율", "ref": fx["키"]}, + {"id": "r", "group": "요율", "ref": rate["키"]}, + {"id": "e", "group": "기계", "ref": eq["키"], "field": "가격"}, + {"id": "e2", "group": "기계", "ref": eq["키"]}, + {"id": "t", "group": "소요량", "table": {"key": SAMPLE}}, + {"id": "t1", "group": "소요량", "table": {"key": SAMPLE, "row": 1}}, + {"id": "u", "group": "재료", "ref": "UE-0007"}, + {"id": "w", "group": "인력", "ref": oil["키"]}, + {"id": "z", "group": "모름", "ref": ""}, + ] + body = {"items": items, "userElements": local} + got = {v["id"]: v for v in _post(client, "/api/m02/df/values", body)["values"]} + assert _d(got["l"]["value"]) == price + assert got["m"]["value"] == want # 검색어 묶음 = 기존 /calc 재료 줄 단가 + assert got["o"]["value"] == oil["값"] and got["f"]["value"] == fx["값"] + assert got["r"]["value"] == rate["값"]["rate_percent"] and got["r"]["unit"] == "%" + assert got["e"]["value"] == eq["값"]["가격"] + assert got["t"]["rows"][0] == HEAD + assert got["t1"]["rows"] == [HEAD, ["견치돌", 4, 4, 0.7, None, None]] + assert got["u"]["value"] == 500 and got["u"]["unit"] == "개" + for k in ("w", "z", "e2"): # 기계 묶음은 칸을 골라야 함 + assert got[k]["ok"] is False and got[k]["reason"], got[k] + + +def test_사용자_요소_만들기_지우기(client: TestClient, folder: Path) -> None: + before = {p.name: p.read_bytes() for p in folder.glob("*.json")} + url = "/api/m02/df/user-elements" + a = _post(client, url, {"op": "add", "element": {"name": "가", "group": "재료", "value": 1.5}}) + b = _post(client, url, {"op": "add", "element": {"name": "나", "unit": "회"}}) + assert [a["element"]["id"], b["element"]["id"]] == ["UE-0001", "UE-0002"] + assert b["element"]["group"] == "재료" and b["element"]["value"] is None + _post(client, url, {"op": "add", "element": {"name": " "}}, want=400) + _post(client, url, {"op": "add", "element": {"name": "다", "value": "x"}}, want=400) + _post(client, url, {"op": "add", "element": {"name": "라", "group": "모름"}}, want=400) + _post(client, url, {"op": "delete", "id": "UE-0001"}) + _post(client, url, {"op": "delete", "id": "UE-0001"}, want=404) + assert [e["id"] for e in client.get(url).json()["elements"]] == ["UE-0002"] + got = _post( + client, "/api/m02/df/values", {"items": [{"id": "x", "group": "재료", "ref": "UE-0002"}]} + ) + assert got["values"][0]["ok"] is False # 값이 비어 있으면 쉬운 말 + assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터에 안 씀 + + +def test_시스템_관리자가_아니면_403(folder: Path) -> None: + app = FastAPI() + app.include_router( + cf.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] + ) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} + client = TestClient(app) + assert client.get("/api/m02/df/list").status_code == 403 + assert client.post("/api/m02/df/values", json={"items": []}).status_code == 403 + assert client.get("/api/m02/df/user-elements").status_code == 403 + app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} + assert [x["key"] for x in client.get("/api/m02/df/list").json()["logics"]] == ["견본 돌쌓기"] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None: + """입력 판(sub3) 기계 ref 「EQ키#칸」 · 물음 {ref, field} · 「시간당 경비」 = 기계경비 로직.""" + logic = min( + (r for r in cf._machine_family() if "지역" in [i["이름"] for i in r["입력"]]), + key=cf._machine_rank, + ) + code = str(cf._codes(logic)[0]) + eq = _first("기계.json", lambda r: str(r.get("원문번호")) == code) + family = [r for r in cf._machine_family() if code in [str(c) for c in cf._codes(r)]] + chosen = min(family, key=cf._machine_rank) + given = { + i["이름"]: (code if i["이름"] == "기계" else (i.get("고르기") or [None])[0]) + for i in chosen["입력"] + } + want = _post(client, "/api/m01/calc", {"key": chosen["키"], "inputs": given})["sums"]["계"] + + items = [ + {"id": "p", "group": "기계", "ref": f"{eq['키']}#가격"}, + {"id": "p2", "group": "기계", "ref": eq["키"], "field": "가격"}, + {"id": "h", "group": "기계", "ref": f"{eq['키']}#시간당 경비"}, + {"id": "h2", "group": "기계", "ref": eq["키"], "field": "시간당 경비"}, + {"id": "x", "group": "기계", "ref": f"{eq['키']}#없는칸"}, + ] + got = {v["id"]: v for v in _post(client, "/api/m02/df/values", {"items": items})["values"]} + assert got["p"]["value"] == got["p2"]["value"] == eq["값"]["가격"] + assert got["h"]["value"] == got["h2"]["value"] == want and got["h"]["unit"] == "원/시간" + assert got["x"]["ok"] is False and "없는칸" not in got["x"].get("value", "") + + # 저장 — 입력 상자 요소 ref 그대로(「EQ키#칸」) + box = _box("m1", "input", "기계", input={"mode": "group", "group": "기계", "variables": [], + "elements": [{"ref": f"{eq['키']}#시간당 경비", "name": "기계 · 시간당 경비", + "spec": "", "unit": "원/시간"}]}) # fmt: skip + f1 = _box("f1", "function", "곱", func=_func( + {"id": "s1", "이름": "곱", "칸": {"B1": {"식": "A1*2"}}}, + [{"port": "단가", "cell": "A1"}], + {"cell": "B1", "name": "경비", "unit": "원"})) # fmt: skip + o1 = _box("o1", "output", "경비", output={"cost": "경비", "unit": "원"}) + body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1], + "links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"), + _link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip + saved = _post(client, "/api/m02/df/save", {"logic": body}) + assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want) + _post(client, "/api/m02/df/delete", {"key": saved["key"]}) diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py new file mode 100644 index 000000000..a5c12d17b --- /dev/null +++ b/resources/tester/test_m02_df_sample.py @@ -0,0 +1,275 @@ +"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`. + +· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) +· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 + (Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만 +· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100 +· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow" +SAMPLE_TS = FOLDER / "M02_DF_Sample.ts" +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip + +HOOKS = r""" +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" + + +def _node(body: str) -> object: + files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} + script = ( + "import { register } from 'node:module';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + "globalThis.__stub = { currentLanguageIndex: 0 };\n" + f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" + f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-2000:] + return json.loads(result.stdout) + + +# ── 모양 ───────────────────────────────────────────────────────────────── +def _ports_out(box: dict) -> list[str]: + if box["kind"] == "function": + return [box["func"]["output"]["name"]] + if box["kind"] == "output": + return [] + inp = box["input"] + return ( + [e["name"] for e in inp.get("elements", [])] + + (["표"] if inp.get("table") else []) + + [v["name"] for v in inp.get("variables", [])] + ) + + +def _ports_in(box: dict) -> list[str]: + if box["kind"] == "function": + return [i["port"] for i in box["func"]["inputs"]] + return ["값"] if box["kind"] == "output" else [] + + +def _order(lg: dict) -> list[dict]: + """함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패""" + boxes = {b["id"]: b for b in lg["boxes"]} + need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip + done: list[str] = [] + while len(done) < len(boxes): + ready = [i for i in boxes if i not in done and need[i] <= set(done)] + assert ready, "선이 돌고 돎" + done += ready + return [boxes[i] for i in done] + + +# Model 기본 색(`M02_DF_Model.ts` BOX_COLOR) — 견본 색은 이것과 같아야 함 +BOX_COLOR = dict( + re.findall( + r'(\w+): "(#[0-9a-fA-F]{6})"', + re.search( + r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8") + ).group(1), + ) +) + + +@pytest.mark.parametrize("name", ["masonry", "rate", "value"]) +def test_견본_모양(name: str) -> None: + lg = SAMPLES[name] + assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] + boxes = {b["id"]: b for b in lg["boxes"]} + assert len(boxes) == len(lg["boxes"]) + sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"] + assert len(set(sheets)) == len(sheets) + for b in lg["boxes"]: + assert b["kind"] in ("input", "function", "output") + assert ( + b["color"] == BOX_COLOR[b["kind"]] + and b["unit"] + and isinstance(b["x"], int) + and isinstance(b["y"], int) + ) + if b["kind"] == "function": + fn = b["func"] + cells = fn["sheet"]["칸"] + assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸 + assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) + if b["kind"] == "output": + assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") + units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []} + if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙) + assert b["unit"] == next(iter(units)), b["id"] + for link in lg["links"]: + a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] + assert link["from"]["name"] in _ports_out(a), link + assert link["to"]["name"] in _ports_in(z), link + # 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오 + for b in lg["boxes"]: + for port in _ports_in(b): + got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] + assert len(got) == 1, (b["id"], port) + for link in lg["links"]: + assert boxes[link["from"]["box"]]["kind"] != "output" + assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"] + _order(lg) + + +def test_견본_목록_열기() -> None: + got = _node( + "out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });" + ) + assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"] + assert all(x["note"] for x in got["list"]) + assert got["one"]["key"] is None and got["one"]["boxes"] == SAMPLES["masonry"]["boxes"] + assert got["none"] is None + + +# ── 값 ─────────────────────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def master(): + from M01_MasterData import M01_MasterData_Store as store + + cm = store.cm + files = cm.load(folder=store.FOLDER) + return cm, files + + +def _table_rows(table: dict) -> list[list]: + """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)""" + conds, lines = table.get("조건") or {}, table.get("줄") or [] + names = [*conds, *(table.get("값칸") or {})] + split = { + n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) + } + head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] + rows = [] + for r in lines: + line: list = [] + for n in names: + v = r.get(n) + if n in split: + line += list(v) if isinstance(v, list) else [v, v] + else: + line.append(v) + rows.append(line) + return [head, *rows] + + +def _plain(v): + return float(v) if isinstance(v, Decimal) else v + + +def _run(lg: dict, master) -> dict: + """상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값""" + from common_util.common_util_spreadsheet import recalc_workbooks + + cm, files = master + m = cm.mf.Master(files) + tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + ports: dict[str, dict] = {} + for b in _order(lg): + into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip + if b["kind"] == "input": + inp = b["input"] + got = {v["name"]: v["example"] for v in inp.get("variables", [])} + got |= { + e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0]) + for e in inp.get("elements", []) + } + if inp.get("table"): + got["표"] = _table_rows(tables[inp["table"]["key"]]) + ports[b["id"]] = got + elif b["kind"] == "function": + fn = b["func"] + sheet = json.loads(json.dumps(fn["sheet"])) + for i in fn["inputs"]: + value = into[i["port"]] + col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) + block = value if isinstance(value, list) else [[value]] + for r, line in enumerate(block): + for c, x in enumerate(line): + if x is not None: + sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} + book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]} + out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] + assert "수" in out, out + ports[b["id"]] = {fn["output"]["name"]: float(out["수"])} + else: + ports[b["id"]] = {"값": into["값"]} + return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} + + +def test_견본_표_그룹이_마스터와_같음(master) -> None: + """표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)""" + cm, files = master + group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + for lg in SAMPLES.values(): + for b in lg["boxes"]: + table = b.get("input", {}).get("table") + if table: + assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"]) + + +def test_견본1_원가_calc_와_같음(master) -> None: + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + cm, files = master + row = next( + r + for d in files.values() + if d.get("그룹") == "로직" + for r in d["줄"] + if r["키"] == "GF000219" + ) + given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} + want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {}) + assert want["ok"], want + got = _run(SAMPLES["masonry"], master)["out-pay"] + print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") + assert abs(got - float(want["sums"]["계"])) < 1 + + +def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + cm, files = master + row = next( + r + for d in files.values() + if d.get("그룹") == "로직" + for r in d["줄"] + if r["키"] == "GF000133" + ) + given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} + given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 + want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {}) + assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) + assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100 From d660f2a12e8e393af82b8f6a82c673b8c906c27c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:37:53 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(m02-df):=20=EB=8B=A8=EC=9C=84=20=C2=B7?= =?UTF-8?q?=20=EA=B8=88=EC=95=A1=20=ED=91=9C=EC=A4=80=20=ED=91=9C=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=EA=B0=92=20=E2=80=94=20=EC=82=B0=EB=A6=BC?= =?UTF-8?q?=ED=92=88=EC=85=88=201-2-2=20=E2=86=92=20DataFlow/=5Fstandard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단위표준.json 73 줄(병합 칸 10 줄 폄 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 줄 · 주 ①~③) · 원문 흠 그대로 - 금액단위표준.json 다섯 줄(지위 · 미만버림) - 시험 test_m02_df_standards.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- .../DataFlow/_standard/금액단위표준.json | 42 ++ .../DataFlow/_standard/단위표준.json | 680 ++++++++++++++++++ resources/tester/test_m02_df_standards.py | 80 +++ 3 files changed, 802 insertions(+) create mode 100644 M02_MasterTemplete/DataFlow/_standard/금액단위표준.json create mode 100644 M02_MasterTemplete/DataFlow/_standard/단위표준.json create mode 100644 resources/tester/test_m02_df_standards.py diff --git a/M02_MasterTemplete/DataFlow/_standard/금액단위표준.json b/M02_MasterTemplete/DataFlow/_standard/금액단위표준.json new file mode 100644 index 000000000..2d19a5bd8 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_standard/금액단위표준.json @@ -0,0 +1,42 @@ +{ + "이름": "금액의 단위표준", + "출처": "산림품셈 1-2-2 2", + "원문": "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md", + "줄": [ + { + "종목": "설계서의 총액", + "단위": "원", + "지위": 1000, + "비고": "미만버림", + "원문 줄": 145 + }, + { + "종목": "설계서의 소계", + "단위": "원", + "지위": 1, + "비고": "미만버림", + "원문 줄": 146 + }, + { + "종목": "설계서의 금액란", + "단위": "원", + "지위": 1, + "비고": "미만버림", + "원문 줄": 147 + }, + { + "종목": "일위대가표의 계금", + "단위": "원", + "지위": 1, + "비고": "미만버림", + "원문 줄": 148 + }, + { + "종목": "일위대가표의 금액란", + "단위": "원", + "지위": 0.1, + "비고": "미만버림", + "원문 줄": 149 + } + ] +} diff --git a/M02_MasterTemplete/DataFlow/_standard/단위표준.json b/M02_MasterTemplete/DataFlow/_standard/단위표준.json new file mode 100644 index 000000000..a8a9f23fb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_standard/단위표준.json @@ -0,0 +1,680 @@ +{ + "이름": "설계서의 단위 및 소수의 표준", + "출처": "산림품셈 1-2-2 1", + "원문": "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md", + "바꿈": "소수자리 「-」 = 0 · 단위 「-」 = 「없음」 · 빈 칸 = null · 병합 칸은 위 종목을 폄(「병합」: true)", + "줄": [ + { + "종목": "공사연장", + "규격 단위": "m", + "규격 소수자리": 2, + "단위수량 단위": "m", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 60 + }, + { + "종목": "공사폭원", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 61 + }, + { + "종목": "직공인부", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "인", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 62 + }, + { + "종목": "공사 및 사업면적", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 63 + }, + { + "종목": "용지면적", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 64 + }, + { + "종목": "토적(높이, 너비)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 65 + }, + { + "종목": "토적(단면적)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 66 + }, + { + "종목": "토적(체적)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 67 + }, + { + "종목": "토적(체적합계)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 68 + }, + { + "종목": "떼", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 69 + }, + { + "종목": "모래, 자갈", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 70 + }, + { + "종목": "조약돌", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 71 + }, + { + "종목": "견치돌, 깬돌", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 72 + }, + { + "종목": "견치돌, 깬돌", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": null, + "비고": "", + "원문 줄": 73, + "병합": true + }, + { + "종목": "야면석(野面石)", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 74 + }, + { + "종목": "돌쌓기 및 돌붙임", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 75 + }, + { + "종목": "돌쌓기 및 돌붙임", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 76, + "병합": true + }, + { + "종목": "돌쌓기 및 돌붙임", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 77, + "병합": true + }, + { + "종목": "돌쌓기 및 돌붙임", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 78, + "병합": true + }, + { + "종목": "사석(捨石)", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 79 + }, + { + "종목": "다듬돌(切石, 板石)", + "규격 단위": "cm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 80 + }, + { + "종목": "벽돌", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 81 + }, + { + "종목": "블록", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 82 + }, + { + "종목": "시멘트", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 83 + }, + { + "종목": "모르타르", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 84 + }, + { + "종목": "콘크리트", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎥", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 85 + }, + { + "종목": "석분", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 86 + }, + { + "종목": "석회", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 87 + }, + { + "종목": "화산회", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 88 + }, + { + "종목": "아스팔트", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 89 + }, + { + "종목": "목재(판재)", + "규격 단위": "길이m", + "규격 소수자리": 1, + "단위수량 단위": "㎡", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 90 + }, + { + "종목": "목재(판재)", + "규격 단위": "폭,두께cm", + "규격 소수자리": 1, + "단위수량 단위": "㎥", + "단위수량 소수자리": 3, + "비고": "", + "원문 줄": 91, + "병합": true + }, + { + "종목": "합판", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "장", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 92 + }, + { + "종목": "말뚝", + "규격 단위": "길이m", + "규격 소수자리": 1, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 93 + }, + { + "종목": "말뚝", + "규격 단위": "지름mm", + "규격 소수자리": 0, + "단위수량 단위": null, + "단위수량 소수자리": null, + "비고": "", + "원문 줄": 94, + "병합": true + }, + { + "종목": "철강재", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 3, + "비고": "", + "원문 줄": 95 + }, + { + "종목": "용접봉", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 96 + }, + { + "종목": "구리판, 함석류", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 97 + }, + { + "종목": "철근", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 98 + }, + { + "종목": "볼트・너트", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 99 + }, + { + "종목": "꺽쇠", + "규격 단위": "mm", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 100 + }, + { + "종목": "철선류", + "규격 단위": "mm", + "규격 소수자리": 1, + "단위수량 단위": "kg", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 101 + }, + { + "종목": "PC강선", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 102 + }, + { + "종목": "돌망태", + "규격 단위": "길이m", + "규격 소수자리": 1, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 103 + }, + { + "종목": "돌망태", + "규격 단위": "지름m", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 104, + "병합": true + }, + { + "종목": "돌망태", + "규격 단위": "높이m", + "규격 소수자리": 0, + "단위수량 단위": null, + "단위수량 소수자리": null, + "비고": "", + "원문 줄": 105, + "병합": true + }, + { + "종목": "로프류", + "규격 단위": "mm", + "규격 소수자리": 1, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 106 + }, + { + "종목": "못", + "규격 단위": "길이cm", + "규격 소수자리": 1, + "단위수량 단위": "kg", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 107 + }, + { + "종목": "석유, 휘발유, 모빌유", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "ℓ", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 108 + }, + { + "종목": "구리스", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 109 + }, + { + "종목": "넝마", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 110 + }, + { + "종목": "화약류", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 3, + "비고": "", + "원문 줄": 111 + }, + { + "종목": "뇌관", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 112 + }, + { + "종목": "도화선", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 113 + }, + { + "종목": "석탄, 목탄, 코크스", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 114 + }, + { + "종목": "산소", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "ℓ", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 115 + }, + { + "종목": "카바이트", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "kg", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 116 + }, + { + "종목": "도료(塗料)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "ℓ또는kg", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 117 + }, + { + "종목": "도장(塗裝)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 118 + }, + { + "종목": "관류(管類)", + "규격 단위": "길이m", + "규격 소수자리": 2, + "단위수량 단위": "개", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 119 + }, + { + "종목": "관류(管類)", + "규격 단위": "지름mm", + "규격 소수자리": 0, + "단위수량 단위": null, + "단위수량 소수자리": null, + "비고": "", + "원문 줄": 120, + "병합": true + }, + { + "종목": "관류(管類)", + "규격 단위": "두께mm", + "규격 소수자리": 0, + "단위수량 단위": null, + "단위수량 소수자리": null, + "비고": "", + "원문 줄": 121, + "병합": true + }, + { + "종목": "수로연장", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 122 + }, + { + "종목": "옹벽", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 123 + }, + { + "종목": "승강장옹벽 및 울타리", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 124 + }, + { + "종목": "궤도부설", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "km", + "단위수량 소수자리": 3, + "비고": "", + "원문 줄": 125 + }, + { + "종목": "시험하중", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "ton", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 126 + }, + { + "종목": "보오링", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 127 + }, + { + "종목": "방수면적", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 128 + }, + { + "종목": "건물(면적)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 2, + "비고": "", + "원문 줄": 129 + }, + { + "종목": "건물(지붕, 벽붙이기)", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "㎡", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 130 + }, + { + "종목": "우물", + "규격 단위": "깊이", + "규격 소수자리": 0, + "단위수량 단위": "m", + "단위수량 소수자리": 1, + "비고": "", + "원문 줄": 131 + }, + { + "종목": "마대", + "규격 단위": "없음", + "규격 소수자리": 0, + "단위수량 단위": "매", + "단위수량 소수자리": 0, + "비고": "", + "원문 줄": 132 + } + ], + "주": [ + "① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용한다.", + "② 품셈 각 항목에서 제시한 소숫자리가 본 표의 내용과 상이할 경우 항목에서 제시하는 소숫자리를 우선하여 적용한다.", + "③ 본 표에 제시하지 않은 품의 경우 유사품의 규격과 단위수량을 참고하여 적용하며, C.G.S 단위로 하는 것을 원칙으로 한다." + ] +} diff --git a/resources/tester/test_m02_df_standards.py b/resources/tester/test_m02_df_standards.py new file mode 100644 index 000000000..26af3dd7b --- /dev/null +++ b/resources/tester/test_m02_df_standards.py @@ -0,0 +1,80 @@ +"""M02 단가산출 흐름도 — 단위 · 금액 표준 표(35-9 · 정함 11) 시험. + +· `_standard/단위표준.json` — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」 표 줄 수 · 병합 칸 폄 · 「-」 바꾸기 · 주 ①~③ +· `_standard/금액단위표준.json` — 「금액의 단위표준」 다섯 줄 · 미만버림 +· 원문 줄 번호가 원문의 그 줄을 가리킴 · 원문 흠은 고치지 않음(사용자가 시험판에서 고침) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STD = ROOT / "M02_MasterTemplete" / "DataFlow" / "_standard" +SRC = ( + ROOT + / "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md" +) +UNIT = json.loads((STD / "단위표준.json").read_text(encoding="utf-8")) +MONEY = json.loads((STD / "금액단위표준.json").read_text(encoding="utf-8")) +LINES = SRC.read_text(encoding="utf-8").split("\n") + + +def _cells(no: int) -> list[str]: + return [c.strip() for c in LINES[no - 1].strip().strip("|").split("|")] + + +def test_단위표준_줄_수_병합_원문줄() -> None: + rows = UNIT["줄"] + # 원문 표 = 머리 · 가름 · 몸 1행(단위 · 소수자리) 다음 73 줄 — 병합 칸 10 줄(주석의 14~15 · 17~20 · 32~33 · 35~36 · 45~47 · 61~63) + assert len(rows) == 73 + assert sum(1 for r in rows if r.get("병합")) == 10 + spread = {r["종목"] for r in rows if r.get("병합")} + assert spread == { + "견치돌, 깬돌", + "돌쌓기 및 돌붙임", + "목재(판재)", + "말뚝", + "돌망태", + "관류(管類)", + } + for r in rows: + cells = _cells(r["원문 줄"]) + assert cells[0] == ("" if r.get("병합") else r["종목"]) + assert [r["종목"] for r in rows[:3]] == ["공사연장", "공사폭원", "직공인부"] + assert rows[-1]["종목"] == "마대" + + +def test_단위표준_바꾸기() -> None: + """소수자리 「-」 = 0 · 단위 「-」 = 「없음」 · 빈 칸 = null · 원문 글자는 그대로""" + for r in UNIT["줄"]: + src = _cells(r["원문 줄"]) + for i, key in [(2, "규격 소수자리"), (4, "단위수량 소수자리")]: + want = 0 if src[i] == "-" else (int(src[i]) if src[i] else None) + assert r[key] == want, (r["원문 줄"], key) + for i, key in [(1, "규격 단위"), (3, "단위수량 단위")]: + want = "없음" if src[i] == "-" else (src[i] or None) + assert r[key] == want, (r["원문 줄"], key) + 공사연장 = UNIT["줄"][0] + assert (공사연장["규격 단위"], 공사연장["규격 소수자리"], 공사연장["단위수량 소수자리"]) == ( + "m", + 2, + 0, + ) + 말뚝 = [r for r in UNIT["줄"] if r["종목"] == "말뚝"] + assert 말뚝[1]["단위수량 단위"] is None # 원문 빈 칸 그대로 + assert [n[:1] for n in UNIT["주"]] == ["①", "②", "③"] + + +def test_금액_다섯_줄() -> None: + rows = MONEY["줄"] + assert [(r["종목"], r["지위"]) for r in rows] == [ + ("설계서의 총액", 1000), + ("설계서의 소계", 1), + ("설계서의 금액란", 1), + ("일위대가표의 계금", 1), + ("일위대가표의 금액란", 0.1), + ] + assert all(r["단위"] == "원" and r["비고"] == "미만버림" for r in rows) + assert all(_cells(r["원문 줄"])[0] == r["종목"] for r in rows)