From 80b195fd0cf2b7b226599c1b8d34086b9cf22602 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:51:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore(M01):=20=EC=89=AC=EC=9A=B4=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B2=80=ED=86=A0=20B?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EB=AC=B8=EC=84=9C=20=E2=80=94=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20docs=20=EC=82=AC=EB=B3=B8=20=EB=8B=B4?= =?UTF-8?q?=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_쉬운단가/쉬운단가_검토_B_화면.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md diff --git a/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md new file mode 100644 index 000000000..66d047bc6 --- /dev/null +++ b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md @@ -0,0 +1,141 @@ +# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만) + +⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜. + +- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인. +- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗. +- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창). + +## 1. 화면 자리 + +| 무엇 | 화면 위치 | 파일:줄 | +| --- | --- | --- | +| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` | +| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` | +| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` | +| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` | +| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` | +| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` | + +- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림. + +## 2. 대표 일 여섯 — 걸음 + +누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수. + +| 일 | 길 | 누름 | 창 | 막힌 곳 | +| --- | --- | --- | --- | --- | +| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 | +| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 | +| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 | +| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 | +| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 | +| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 | + +### ① 새 로직 — 「식으로 새로 만들기」 (실측) + +1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4 +2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2 +3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17 +4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12 +5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3 +6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1) +7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐. +8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함. + +### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측) + +- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」). +- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10). +- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄. +- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸. +- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더. + +### ③ 수량 바꾸기 + +- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글. +- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김. +- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함. + +### ④ 단가 자료(자재 · 노임) 바꾸기 + +- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3. +- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가. +- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐. +- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로. +- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움. + +### ⑤ 줄 지우기 + +- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음. +- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음. +- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음. + +### ⑥ 로직 지우기 · 되돌리기 + +- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소). +- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘. +- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘). +- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음). +- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐. + +## 3. 처음 보는 사람이 모를 낱말 + +- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘 +- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본 +- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기 +- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐 +- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC +- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}` +- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」 +- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표 +- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임) + +## 4. 화면에 안 보이는 규칙 + +1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로. +2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만. +3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`). +4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘. +5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과. +6. 변수 목록은 식 순서가 아니라 가나다 순. +7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦. +8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나. +9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함. +10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음. +11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤. +12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림. +13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함. +14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」. + +## 5. 다섯 기능 — 하려던 것 · 어려운 것 + +- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함. +- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음. +- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」. +- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음. +- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004). + +## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게) + +| # | 흠 | 자리 | 사진 | +| --- | --- | --- | --- | +| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 | +| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — | +| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 | +| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 | +| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — | +| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 | +| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 | + +## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫) + +1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림. +2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임. +3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게. +4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로. +5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀. +6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직. +7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기. +8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로. +9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기. From 3d52720759449fa98e2727240a29363bedda5ca9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:52:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(m01-easycalc):=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=EC=B0=BD=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openEasyCond(card, doc, trial) — 잠금 카드 수량 · 그 밖 덧붙임의 조건을 문장으로 · 고르기 · 숫자 칸으로 고쳐 새 식 글 · 취소 null - 읽는 꼴: 만약(사슬) · 최소/최대 · 올림/버림/반올림 — 식 전체 또는 식 속 덩어리 · 중간 값 이름 하나면 따라감 · 못 읽으면 지금 화면 안내 한 줄 - 금액 미리 보기: Api calcLater · 준비 전이면 /calc 0.3초 뒤 최신만 - 표 칩은 Table 파일이 있으면 openEasyTable 로 엶(import.meta.glob) - 마스터: 줄 자체에 조건이 있는 카드 824 가운데 767 읽음(93%) - 시험 resources/tester/test_m01_easycalc_cond.py 19개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css | 97 +++ M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts | 620 ++++++++++++++++++ resources/tester/test_m01_easycalc_cond.py | 198 ++++++ 3 files changed, 915 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts create mode 100644 resources/tester/test_m01_easycalc_cond.py diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css new file mode 100644 index 000000000..892145742 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css @@ -0,0 +1,97 @@ +/* ============================================================================= + * M01_EasyCalc_Cond.css — 조건 보고 고치기 창 + * ========================================================================== */ + +.m01-easycond { + width: min(760px, calc(100vw - 32px)); +} + +.m01-easycond__lines { + display: flex; + flex-direction: column; + gap: 12px; +} + +.m01-easycond__line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 16px; + line-height: 1.6; +} + +.m01-easycond__slot { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.m01-easycond__select, +.m01-easycond__num { + min-height: 36px; + padding: 4px 8px; + border: 1px solid var(--color-border, #c9ced6); + border-radius: 8px; + background: var(--color-surface, #fff); + color: inherit; + font: inherit; +} + +.m01-easycond__num { + width: 7em; + text-align: right; +} + +.m01-easycond__num.is-bad { + border-color: var(--color-danger, #d64545); + outline: 2px solid var(--color-danger, #d64545); +} + +.m01-easycond__word { + padding: 4px 8px; + border-radius: 8px; + background: var(--color-surface-raised, #eef1f5); + font-weight: 600; +} + +.m01-easycond__chip { + min-height: 36px; + padding: 4px 12px; + border: 1px dashed var(--color-accent, #3a6fd8); + border-radius: 999px; + background: transparent; + color: var(--color-accent, #3a6fd8); + font: inherit; + cursor: pointer; +} + +.m01-easycond__chip:disabled { + border-color: var(--color-border, #c9ced6); + color: inherit; + cursor: default; +} + +.m01-easycond__money { + margin-top: 16px; + padding: 12px; + border-radius: 8px; + background: var(--color-surface-raised, #eef1f5); + font-size: 16px; + font-weight: 600; +} + +.m01-easycond__money.is-waiting { + opacity: 0.5; +} + +.m01-easycond__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.m01-easycond__cannot { + font-size: 16px; +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts new file mode 100644 index 000000000..fb304fc91 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts @@ -0,0 +1,620 @@ +/* ============================================================================= + * M01_EasyCalc_Cond.ts + * 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고 + * 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null + * + * 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음 + * 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리) + * 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로 + * 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로) + * 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄 + * 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + runCalc, + type CalcAnswer, + type LogicInput, + type LogicRow, +} from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { calcLater } from "./M01_EasyCalc_Api"; +import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model"; +import "./M01_EasyCalc_Cond.css"; + +const TEXT = { + Title: ["조건 보고 고치기", "Change by conditions"], + Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"], + Part: ["조건", "Condition"], + If: ["만약", "If"], + ElseIf: ["아니고 만약", "Else if"], + Then: ["→", "→"], + Else: ["아니면 →", "Otherwise →"], + Subject: ["가", "is"], + Big: ["가운데 큰 값", "whichever is larger"], + Small: ["가운데 작은 값", "whichever is smaller"], + And: ["와", "and"], + RoundA: ["을 소수", "to"], + RoundB: ["자리까지", "decimal places,"], + Up: ["올림", "round up"], + Down: ["버림", "round down"], + Half: ["반올림", "round off"], + OpEq: ["이면", "equal to"], + OpNe: ["이 아니면", "not equal to"], + OpGt: ["보다 크면", "greater than"], + OpGe: ["이상이면", "at least"], + OpLt: ["보다 작으면", "less than"], + OpLe: ["이하이면", "at most"], + ChipTable: ["표에서 찾은 값", "Value from table"], + ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"], + ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"], + ChipCalc: ["계산한 값", "Calculated value"], + ToNumber: ["숫자로", "Use a number"], + Line: ["이 줄", "This line"], + Total: ["합계", "Total"], + Was: ["전", "before"], + Waiting: ["계산 중 …", "Calculating …"], + Use: ["이걸로", "Use this"], + Cancel: ["취소", "Cancel"], + NotNumber: ["숫자만 넣음", "Numbers only"], +} as const; + +const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */ + +/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */ +type CNode = { src: string; p: [number, number] } & ( + | { t: "num" | "str" | "name" | "ref"; v: string } + | { t: "call"; f: string; items: CNode[] } + | { t: "bin"; op: string; a: CNode; b: CNode } + | { t: "neg" | "paren"; a: CNode } + | { t: "field"; a: CNode; f: string } +); + +const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"]; +const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"]; +const NAME_RE = /[\p{L}\p{N}_]+/uy; +const NUM_RE = /\d+(?:\.\d+)?/y; + +/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */ +function parseCond(s: string): CNode | null { + let i = 0; + const fail = (): never => { + throw new Error("parse"); + }; + const ws = (): void => { + while (i < s.length && /\s/.test(s[i])) i++; + }; + const take = (w: string): boolean => { + ws(); + if (!s.startsWith(w, i)) return false; + i += w.length; + return true; + }; + const need = (w: string): void => void (take(w) || fail()); + const mk = (start: number, body: T): CNode => + ({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode; + const sticky = (re: RegExp): string | null => { + re.lastIndex = i; + const m = re.exec(s); + if (m) i = re.lastIndex; + return m ? m[0] : null; + }; + const word = (): string => (ws(), sticky(NAME_RE) ?? fail()); + const quoted = (): string => { + const end = s.indexOf("'", i); + if (end < 0) fail(); + const v = s.slice(i, end); + i = end + 1; + return v; + }; + /** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */ + const skipGroup = (): void => { + let depth = 0; + let q = false; + for (; i < s.length; i++) { + if (s[i] === "'") q = !q; + else if (!q && s[i] === "(") depth++; + else if (!q && s[i] === ")" && --depth === 0) return void i++; + } + fail(); + }; + /** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */ + const skipRef = (): void => { + for (let depth = 0; i < s.length; i++) { + const ch = s[i]; + if ("({".includes(ch)) depth++; + else if (")}".includes(ch) && depth-- === 0) return; + else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return; + else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return; + } + }; + const fields = (start: number, node: CNode): CNode => { + while (take(".")) { + const f = take("'") ? quoted() : word(); + node = mk(start, { t: "field", a: node, f }); + } + return node; + }; + const primary = (): CNode => { + ws(); + const start = i; + if (take("(")) { + const a = expr(); + need(")"); + return fields(start, mk(start, { t: "paren", a })); + } + if (take("'")) return mk(start, { t: "str", v: quoted() }); + const num = sticky(NUM_RE); + if (num) return mk(start, { t: "num", v: num }); + const w = word(); + if (w === "찾기" || w === "로직") { + ws(); + if (s[i] !== "(") fail(); + skipGroup(); + return fields(start, mk(start, { t: "ref", v: w })); + } + if (COND_FUNCS.includes(w)) { + need("("); + const items = [expr()]; + while (take(",")) items.push(expr()); + need(")"); + return fields(start, mk(start, { t: "call", f: w, items })); + } + if (s[i] === ":") { + i++; + skipRef(); + return fields(start, mk(start, { t: "ref", v: w })); + } + return fields(start, mk(start, { t: "name", v: w })); + }; + const unary = (): CNode => { + ws(); + const start = i; + return take("-") ? mk(start, { t: "neg", a: unary() }) : primary(); + }; + const chain = (next: () => CNode, ops: string[], once = false): CNode => { + ws(); + const start = i; + let node = next(); + for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) + node = mk(start, { t: "bin", op, a: node, b: next() }); + return node; + }; + const mul = (): CNode => chain(unary, ["*", "/"]); + const add = (): CNode => chain(mul, ["+", "-"]); + const compare = (): CNode => chain(add, COMPARE_OPS, true); + const both = (): CNode => chain(compare, ["그리고"]); + const expr = (): CNode => chain(both, ["또는"]); + try { + const node = expr(); + ws(); + return i === s.length ? node : null; + } catch { + return null; + } +} + +/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */ +export type CondSlot = + | { kind: "num"; value: number } + | { kind: "text"; value: string } + | { kind: "input"; name: string } + | { kind: "table" | "calc"; src: string }; +export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<="; +export interface CondTest { + left: CondSlot; + op: CondOp; + right: CondSlot; +} +export type CondShape = + | { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot } + | { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] } + | { + type: "round"; + fn: "올림" | "버림" | "반올림"; + value: CondSlot; + places: number; + hadPlaces: boolean; + }; +/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */ +export interface CondBlock { + shape: CondShape; + p: [number, number]; +} +/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */ +export interface CondRead { + text: string; + via: string | null; + blocks: CondBlock[]; +} + +const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n); + +function slotOf(node: CNode, inputs: LogicInput[]): CondSlot { + const n = unparen(node); + if (n.t === "num") return { kind: "num", value: Number(n.v) }; + if (n.t === "neg" && unparen(n.a).t === "num") + return { kind: "num", value: -Number(unparen(n.a).src) }; + if (n.t === "str") return { kind: "text", value: n.v }; + if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v }; + if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기")) + return { kind: "table", src: n.src }; + return { kind: "calc", src: node.src.trim() }; +} + +function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null { + if (n.t !== "call") return null; + const slot = (x: CNode): CondSlot => slotOf(x, inputs); + if (n.f === "만약") { + const t = unparen(n.items[0]); + if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null; + const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) }; + const branch = { test, value: slot(n.items[1]) }; + const rest = shapeOf(unparen(n.items[2]), inputs); + return rest?.type === "if" + ? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise } + : { type: "if", branches: [branch], otherwise: slot(n.items[2]) }; + } + if (n.f === "최소" || n.f === "최대") + return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null; + const places = n.items[1] ? unparen(n.items[1]) : null; + if (n.items.length > 2 || (places && places.t !== "num")) return null; + const fn = n.f as "올림" | "버림" | "반올림"; + const value = slot(n.items[0]); + return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places }; +} + +const condNum = (n: number): string => { + const t = String(Number(n.toPrecision(12))); + return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; +}; + +function slotText(s: CondSlot): string { + if (s.kind === "num") return condNum(s.value); + if (s.kind === "text") return `'${s.value}'`; + return s.kind === "input" ? s.name : s.src; +} + +/** 덩어리 → 식 글 */ +export function writeCond(shape: CondShape): string { + if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`; + if (shape.type === "round") { + const v = slotText(shape.value); + return shape.places === 0 && !shape.hadPlaces + ? `${shape.fn}(${v})` + : `${shape.fn}(${v}, ${condNum(shape.places)})`; + } + return shape.branches.reduceRight((acc, b) => { + const t = b.test; + return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`; + }, slotText(shape.otherwise)); +} + +const flat = (s: string): string => s.replace(/[\s()]+/g, ""); + +/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */ +function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] { + if (n.t === "call") { + const shape = shapeOf(n, inputs); + if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }]; + return n.items.flatMap((x) => blocksOf(x, inputs)); + } + if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)]; + if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs); + return []; +} + +/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */ +export function readCond(formula: string, row: Pick): CondRead | null { + let text = formula.trim(); + let via: string | null = null; + for (let hop = 0; hop < 5; hop++) { + const mid = (row.중간 ?? []).find((m) => m.이름 === text); + if (!mid) break; + via = mid.이름; + text = mid.식.trim(); + } + const node = parseCond(text); + const blocks = node ? blocksOf(node, row.입력 ?? []) : []; + return blocks.length ? { text, via, blocks } : null; +} + +/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */ +export function writeRead(read: CondRead): string { + let out = read.text; + for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0])) + out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]); + return out; +} + +/* ── 화면 ───────────────────────────────────────────────────────────────── */ + +const OPS: [CondOp, keyof typeof TEXT][] = [ + ["==", "OpEq"], + ["!=", "OpNe"], + [">", "OpGt"], + [">=", "OpGe"], + ["<", "OpLt"], + ["<=", "OpLe"], +]; + +/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */ +const TABLE_MODULE = import.meta.glob<{ + openEasyTable: ( + card: EasyCard, + doc: EasyDoc, + trial?: Record, + ) => Promise; +}>("./M01_EasyCalc_Table.ts"); + +/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */ +const put = (slot: CondSlot, next: CondSlot): void => { + for (const k of Object.keys(slot)) delete (slot as Record)[k]; + Object.assign(slot, next); +}; + +function select( + options: { value: string; text: string }[], + value: string, + onChange: (v: string) => void, +): HTMLSelectElement { + const sel = el("select", { className: "m01-easycond__select" }); + const all = options.some((o) => o.value === value) + ? options + : [...options, { value, text: value }]; + for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } })); + sel.value = value; + sel.addEventListener("change", () => onChange(sel.value)); + return sel; +} + +function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement { + const box = el("input", { + className: "m01-easycond__num", + attrs: { type: "text", inputmode: "decimal", value: condNum(value) }, + }); + box.addEventListener("input", () => { + const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value); + box.classList.toggle("is-bad", !ok); + box.title = ok ? "" : te("NotNumber"); + if (ok) onChange(Number(box.value)); + }); + return box; +} + +function chipLabel(slot: CondSlot, inputs: LogicInput[]): string { + if (slot.kind === "table") return te("ChipTable"); + if (slot.kind !== "calc") return ""; + if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc"); + const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? []; + return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc"); +} + +/** 문장 짓기 — 고칠 때마다 changed() */ +class Sentence { + constructor( + private card: EasyCard, + private doc: EasyDoc, + private changed: () => void, + private trial: Record, + ) {} + + /** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */ + slot(s: CondSlot): HTMLElement { + const host = el("span", { className: "m01-easycond__slot" }); + const draw = (): void => { + host.replaceChildren(); + if (s.kind === "num") { + host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed()))); + return; + } + if (s.kind === "text" || s.kind === "input") { + const text = s.kind === "text" ? s.value : s.name; + host.append(el("span", { className: "m01-easycond__word", text })); + return; + } + const chip = el("button", { + className: "m01-easycond__chip", + text: chipLabel(s, this.doc.inputs), + attrs: { type: "button" }, + }); + const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"]; + if (load && s.kind === "table") + chip.addEventListener("click", async () => { + const got = await ( + await load() + ).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial); + if (got) (put(s, { kind: "table", src: got }), draw(), this.changed()); + }); + else chip.disabled = true; + const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed()); + host.append( + chip, + createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }), + ); + }; + draw(); + return host; + } + + /** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */ + test(t: CondTest): HTMLElement[] { + const inputs = this.doc.inputs; + const right = el("span", { className: "m01-easycond__slot" }); + const drawRight = (): void => { + const name = t.left.kind === "input" ? t.left.name : null; + const inp = inputs.find((x) => x.이름 === name); + const choices = inp?.고르기 ?? []; + const r = t.right; + if (!choices.length || (r.kind !== "text" && r.kind !== "num")) + return right.replaceChildren(this.slot(r)); + const pickOne = (v: string): void => { + const asNum = choices.some((c) => typeof c === "number" && String(c) === v); + put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v }); + this.changed(); + }; + const opts = choices.map((c) => ({ value: String(c), text: String(c) })); + right.replaceChildren(select(opts, String(r.value), pickOne)); + }; + drawRight(); + const l = t.left; + const left = + l.kind === "input" + ? select( + inputs.map((x) => ({ value: x.이름, text: x.이름 })), + l.name, + (v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()), + ) + : this.slot(l); + const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) })); + const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed())); + return [left, el("span", { text: te("Subject") }), right, op]; + } + + /** 덩어리 하나 → 줄들 */ + block(shape: CondShape): HTMLElement[] { + if (shape.type === "if") + return [ + ...shape.branches.map((b, i) => + line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)), + ), + line(te("Else"), this.slot(shape.otherwise)), + ]; + if (shape.type === "pick") { + const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)])); + const opts = [ + { value: "최대", text: te("Big") }, + { value: "최소", text: te("Small") }, + ]; + const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed())); + return [line(...kids, fn)]; + } + const ways = [ + { value: "올림", text: te("Up") }, + { value: "버림", text: te("Down") }, + { value: "반올림", text: te("Half") }, + ]; + const setPlaces = (v: number): void => ( + (shape.places = Math.max(0, Math.round(v))), + this.changed() + ); + const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed())); + const places = numberBox(shape.places, setPlaces); + return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)]; + } +} + +const line = (...kids: (HTMLElement | string)[]): HTMLElement => + el("div", { + className: "m01-easycond__line", + children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)), + }); + +/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */ +function moneyView(card: EasyCard, doc: EasyDoc, trial: Record) { + const box = el("div", { className: "m01-easycond__money", text: te("Waiting") }); + const ho = doc.cards.filter( + (c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"), + ); + const at = ho.findIndex((c) => c.id === card.id); + let first: [number | null, number | null] | null = null; + let seq = 0; + let timer = 0; + const show = (ans: CalcAnswer | null, plain: string | null): void => { + box.classList.remove("is-waiting"); + if (!ans || !ans.ok) + return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : "")); + const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null; + const sums = ans.sums ?? {}; + const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0); + first ??= [lineSum, total]; + const part = (label: string, v: number | null, was: number | null): string => + v === null + ? "" + : `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`; + const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])]; + box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t }))); + }; + const update = (text: string): void => { + box.classList.add("is-waiting"); + const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null })); + const mine = ++seq; + const done = (ans: CalcAnswer | null, plain: string | null = null): void => + void (mine === seq && show(ans, plain)); + try { + calcLater(row, trial, done); + } catch { + // Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만) + window.clearTimeout(timer); + timer = window.setTimeout(() => { + runCalc({ key: "", inputs: trial, row }).then( + (a) => done(a), + () => done(null), + ); + }, 300); + } + }; + return { box, update }; +} + +/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */ +export function openEasyCond( + card: EasyCard, + doc: EasyDoc, + trial: Record = {}, +): Promise { + const read = readCond(card.qtyFormula ?? "", doc.row); + return new Promise((resolve) => { + let done = false; + const finish = (v: string | null): void => { + if (!done) ((done = true), resolve(v)); + }; + openModal({ + title: te("Title"), + closeLabel: te("Cancel"), + dialogClass: "m01-easycond", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + if (!read) + return void body.append( + el("p", { className: "m01-easycond__cannot", text: te("Cannot") }), + ); + const base = flat(writeRead(read)); + const money = moneyView(card, doc, trial); + const use = createButton({ + label: te("Use"), + variant: "filled", + disabled: true, + onClick: () => (finish(writeRead(read)), close()), + }); + const changed = (): void => { + const text = writeRead(read); + use.disabled = flat(text) === base; + money.update(text); + }; + const say = new Sentence(card, doc, changed, trial); + const lines = el("div", { className: "m01-easycond__lines" }); + read.blocks.forEach((b, i) => { + if (read.blocks.length > 1) + lines.append( + el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }), + ); + lines.append(...say.block(b.shape)); + }); + const cancel = createButton({ + label: te("Cancel"), + variant: "ghost", + onClick: () => (finish(null), close()), + }); + const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] }); + body.append(lines, money.box, foot); + money.update(writeRead(read)); + }, + }); + }); +} diff --git a/resources/tester/test_m01_easycalc_cond.py b/resources/tester/test_m01_easycalc_cond.py new file mode 100644 index 000000000..2d74cabf7 --- /dev/null +++ b/resources/tester/test_m01_easycalc_cond.py @@ -0,0 +1,198 @@ +"""M01 쉬운 단가산출 조건 보고 고치기 시험(34-3c) — 읽기 · 짜기 구역을 떼어 Node 로 돌림 · 화면 조작은 ORCA. + +· 견본 식은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 +· 만약(사슬) · 최소/최대 · 올림 · 식 속 덩어리 · 중간 값 따라가기 · 못 읽는 꼴 · 고친 뒤 짜기 왕복 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +COND = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Cond.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +INPUTS = [ + {"이름": "비탈구배", "고르기": ["해당 없음", "1:1.0", "1:1.5"]}, + {"이름": "뒷길이", "단위": "㎝"}, + {"이름": "교환측", "고르기": ["양측", "한측"]}, + {"이름": "층수", "고르기": [1, 2, 3]}, +] +ROW = {"입력": INPUTS, "중간": [{"이름": "석공품", "식": "만약(뒷길이 > 40, 0.4, 0.3)"}]} + + +def _run(body: str, *args: object) -> object: + """읽기 · 짜기 구역 + 시험 글 — `A` = 넘긴 인자 · `out(x)` 로 답""" + src = COND.read_text(encoding="utf-8") + part = src[src.index("/* ── 읽기 · 짜기") : src.index("/* ── 화면")] + script = ( + "type LogicInput = any; type LogicRow = any;\n" + + part + + "\nconst A = JSON.parse(process.argv[1]);\n" + + "const out = (x: unknown) => console.log(JSON.stringify(x));\n" + + body + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(list(args))], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _read(formula: str) -> dict | None: + return _run("out(readCond(A[0], A[1]));", formula, ROW) + + +def _edit(formula: str, js: str) -> str: + """읽은 뒤 `js` 로 덩어리(`B` = 첫 덩어리 꼴)를 고치고 다시 짬""" + return _run( + f"const R = readCond(A[0], A[1]); const B = R.blocks[0].shape; {js}; out(writeRead(R));", + formula, + ROW, + ) + + +# ── 읽기 ─────────────────────────────────────────────────────────────────── +def test_만약_한겹_설계값_표() -> None: + f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)" + r = _read(f) + assert r["via"] is None and r["text"] == f and len(r["blocks"]) == 1 + b = r["blocks"][0] + assert b["p"] == [0, len(f)] + assert b["shape"] == { + "type": "if", + "branches": [ + { + "test": { + "left": {"kind": "input", "name": "비탈구배"}, + "op": "==", + "right": {"kind": "text", "value": "해당 없음"}, + }, + "value": {"kind": "num", "value": 0}, + } + ], + "otherwise": {"kind": "table", "src": "찾기(QF000001, 구배=비탈구배).값"}, + } + + +def test_만약_사슬() -> None: + r = _read("만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))") + shape = r["blocks"][0]["shape"] + assert [x["value"] for x in shape["branches"]] == [ + {"kind": "num", "value": 2}, + {"kind": "num", "value": 3.5}, + ] + assert shape["otherwise"] == {"kind": "num", "value": -1} + assert shape["branches"][1]["test"]["right"] == {"kind": "num", "value": 2} + + +def test_최대_올림() -> None: + pick = _read("최대(시공량 * 0.5, 3)")["blocks"][0]["shape"] + assert pick == { + "type": "pick", + "fn": "최대", + "items": [{"kind": "calc", "src": "시공량 * 0.5"}, {"kind": "num", "value": 3}], + } + up = _read("올림(찾기(QC000001).값 / 시공량, 2)")["blocks"][0]["shape"] + assert (up["type"], up["fn"], up["places"], up["hadPlaces"]) == ("round", "올림", 2, True) + assert up["value"] == {"kind": "calc", "src": "찾기(QC000001).값 / 시공량"} + assert _read("버림(뒷길이)")["blocks"][0]["shape"]["hadPlaces"] is False + + +def test_식_속_덩어리와_중간값() -> None: + f = "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)" + r = _read(f) + assert len(r["blocks"]) == 1 and r["blocks"][0]["p"] == [f.index("만약"), len(f)] + many = _read("(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100") + assert [b["shape"]["type"] for b in many["blocks"]] == ["if", "pick"] + via = _read("석공품") + assert via["via"] == "석공품" and via["text"] == "만약(뒷길이 > 40, 0.4, 0.3)" + + +@pytest.mark.parametrize( + "formula", + [ + "만약(뒷길이 > 40 그리고 층수 < 2, 1, 0)", + "찾기(QC000001, 항목=만약(교환측 == '양측', 'a', 'b')).수량", + "찾기(QC000001).값 * 2", + "0.5", + "만약(뒷길이 > 40, 1", + "", + ], + ids=["그리고", "찾기속", "조건없음", "숫자", "괄호깨짐", "빈글"], +) +def test_못_읽는_꼴(formula: str) -> None: + assert _read(formula) is None + + +# ── 짜기 왕복 ─────────────────────────────────────────────────────────────── +@pytest.mark.parametrize( + "formula", + [ + "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)", + "만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))", + "최소(시공량 * 0.5, 3, 뒷길이)", + "반올림(찾기(QC000001).값 / 시공량, 2)", + "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65) * (1 + 할증 / 100)", + "(용접공 * LB000012 + 특별인부 * LB000003) * 만약(교환측 == '양측', 22, 18) / 100", + ], +) +def test_안_고치면_같은_글(formula: str) -> None: + assert _run("out(writeRead(readCond(A[0], A[1])));", formula, ROW) == formula + + +def test_고친_뒤_짜기() -> None: + f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)" + assert ( + _edit(f, "B.branches[0].test.right.value = '1:1.5'; B.branches[0].test.op = '!='") + == "만약(비탈구배 != '1:1.5', 0, 찾기(QF000001, 구배=비탈구배).값)" + ) + assert ( + _edit(f, "B.otherwise = { kind: 'num', value: 0.25 }") + == "만약(비탈구배 == '해당 없음', 0, 0.25)" + ) + assert ( + _edit( + "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)", + "B.branches[0].value.value = 1.1", + ) + == "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1.1, 0.65)" + ) + assert ( + _edit("최대(시공량 * 0.5, 3)", "B.fn = '최소'; B.items[1].value = 4") + == "최소(시공량 * 0.5, 4)" + ) + assert _edit("올림(뒷길이 / 3)", "B.places = 1; B.fn = '반올림'") == "반올림(뒷길이 / 3, 1)" + assert _edit("석공품", "B.branches[0].test.left.name = '층수'; B.otherwise.value = 0.2") == ( + "만약(층수 > 40, 0.4, 0.2)" + ) + + +def test_둘째_덩어리_고침() -> None: + f = "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100" + got = _run( + "const R = readCond(A[0], A[1]); R.blocks[1].shape.items[1].value = 45; out(writeRead(R));", + f, + ROW, + ) + assert got == "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 45)) / 100" + + +# ── 화면 글 ───────────────────────────────────────────────────────────────── +def test_화면_글에_금지_낱말_없음() -> None: + src = COND.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 len(korean) >= 30 + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"): + assert bad not in text, (bad, text)