From 61329b8f16e02e44285d9ab1ccf6d52f15ab413d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:41:51 +0900 Subject: [PATCH 01/20] =?UTF-8?q?chore(M01):=20=EB=A1=9C=EC=A7=81=ED=8C=90?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=20=EB=A8=BC=EC=A0=80=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/로직판_A_설계초안.md | 205 ++++++++++++++++++ docs/raw/M01_쉬운단가/로직판_A_정함.md | 40 ++++ docs/raw/M01_쉬운단가/로직판_엑셀로직_지도.md | 161 ++++++++++++++ .../로직판_조사_A_견적프로그램.md | 100 +++++++++ .../M01_쉬운단가/로직판_조사_B_노코드도구.md | 95 ++++++++ 5 files changed, 601 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/로직판_A_설계초안.md create mode 100644 docs/raw/M01_쉬운단가/로직판_A_정함.md create mode 100644 docs/raw/M01_쉬운단가/로직판_엑셀로직_지도.md create mode 100644 docs/raw/M01_쉬운단가/로직판_조사_A_견적프로그램.md create mode 100644 docs/raw/M01_쉬운단가/로직판_조사_B_노코드도구.md diff --git a/docs/raw/M01_쉬운단가/로직판_A_설계초안.md b/docs/raw/M01_쉬운단가/로직판_A_설계초안.md new file mode 100644 index 000000000..3b105749a --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_A_설계초안.md @@ -0,0 +1,205 @@ +# 로직 만들기 판 시험판 A(세 칸 판) — 조사 · 설계 초안 (35-2 · sub7) + +조사 · 초안만 · 코드 · git · docs 안 건드림. 셈은 `resources/master_data/로직_*.json` 1,469개 · 표 3,033개(시험 해석기 = 저장소 밖 scratch `lb/survey.ts` · 엔진 `master_formula._Parser` 와 같은 문법). + +## 0. 한눈에 + +| 물음 | 답 | +| --- | --- | +| ① 블록으로 읽고 다시 쓰기 | **됨** — 6개 모두 다 읽힘 · 다시 쓰기 같음. 마스터 **1,396 / 1,469(95%)** 가 ①~⑤ 블록으로 다 읽힘 · 식 하나하나가 문장 틀(값 · 곱나눔 · 할증 · 조건 목록 · 최소최대 · 올림)로 읽히는 것 1,299(88%) | +| ② 표 칸 고치면 시험 계산에 바로 | **지금 안 됨** — `/calc` 에 표 덮어쓰기 길 없음 · 현장(자체) 표 키 없음(QF · QC · CF · CC 뿐) → 서버 두 가지 필요(⬜) | +| ③ 단계 안내로 새로 만들기 | **거의 됨** — `/sections` · `/tables?section=` · `/table` · `/table/options` · `/logic/new` 다 있음 · 「로직에 안 이어진 표」 거름 칸만 없음(절 단위 `logics` 수로 대신 가능) | +| ④ 해당 없음/중복 · 빈틈 · 기대값 시험표 | 해당 없음/중복 = `/calc` 줄 까닭 글로 지금 됨(글 해석) · 빈틈 = 길 없음(서버 하나 ⬜) · 기대값 시험표 = `/calc` 여러 번으로 지금 됨 · 저장 자리는 정할 것 | +| ⑤ 파일 나누기 | 7절 — 6 파일 + 서버 3 길(⬜) · 창 다섯 | + +## 1. 블록 구조 — 로직 한 벌을 ①~⑤ 로 + +### 1-1 블록 모양 + +| 블록 | 지금 로직 칸 | 블록이 쥘 것 | +| --- | --- | --- | +| ① 입력값 | `입력` | 이름 · 단위 · 선택 목록(`고르기`) · 범위 · 설명 · 출처 — 선택 목록은 표 조건 칸에서 저절로(`/table/options`) | +| ② 표 조회 | 식 속 `찾기(표, 조건=값 …).결과칸` | 표 키 · 조건 칸마다 **입력값 / 고정값 / 계산값** · 결과 칸 · 쓰인 자리(중간 · 수량 · 비율 · 결과) | +| ③ 계산 | `중간` + 수량 · 비율 · 결과 식의 나머지 | 이름 붙은 값 · 틀: 값 · 곱나눔(사슬) · 할증(`× (1 + p/100)`) · 더하기 · 조건 목록(만약 사슬) · 최소/최대 · 올림/버림/반올림 | +| ④ 항목 연결 | `호표` · `덧줄` | 종류(노무 · 재료 · 장비 · 일위대가 · 비율) · 요소 · 수량 출처(② · ③ · 숫자) · 비목 · 비율은 바탕(비목 · 사람 줄 노임 합) × 율 | +| ⑤ 결과값 | `결과단위` · `결과` · `끝수` | 금액(원/…) 또는 값(㎥/시간 — `결과` 식) · 다른 로직이 부르는 자리(`로직(키, 넘길 값)`) | + +다시 쓰기 = **글 갈아끼우기** — 고친 조각의 자리만 새 글로 바꾸고 나머지 글자는 그대로(Cond `writeRead` · Table `replaceFinds` 와 같은 길). 안 고친 로직은 바이트까지 같음. + +### 1-2 대상 6개 + +| 로직 | ① | ② | ③ | ④ | ⑤ | 읽기 | +| --- | --- | --- | --- | --- | --- | --- | +| GF000208 암거이음받침 채움재 | 없음 | 없음 | 없음 | 재료 1(검색어 JOINT FILLER · 1㎡) | 원/㎡ | 다 읽힘 | +| GF000219 돌쌓기 메쌓기(인력) | 뒷길이 · 돌 · 쌓기(선택) · 높이 · 초과증가율(범위 80~100) | QF000421(조건 셋 → 석공 · 보통인부) · QF000422(높이 범위 → 증가율) | 증가율 = 조건 목록(높이 ≤3 → 0 · ≤7.5 → 표 · 그 외 → 초과증가율) · 수량 = 표 × 할증 | 노무 2 | 원/㎡ | 다 읽힘 | +| GF000025 떼붙임(재배잔디) | 품(숫자) · 비탈구배(선택) | QF000099(구분 고정 → 값) | 품 × (1 + 조건 목록(해당 없음 → 0 · 그 외 → 표) / 100) | 노무 1 | 원/100㎡ | 다 읽힘 | +| GF000249 다짐(플레이트콤펙터 1.5ton) | 지역(선택) | 없음 | 작업량 = 다른 로직 결과값(GF000114) · 수량 = 1 ÷ 작업량 | 장비 1(기계경비 GC000272 · 넘길 값 기계 고정 · 지역 입력값) | 원/㎥ | 다 읽힘 | +| GF000114 다짐 시간당 작업량 | 없음 | QF000289 여섯 번(기호 고정 A · N · H · f · E · P → 적용) | 결과 = 반올림(A×N×H×f×E÷P, 3) | 없음 | 값 ㎥/시간 | 다 읽힘 | +| GF000133 덤프트럭 시간당 작업량 | qt · f · E · Cm(숫자) | 없음 | 결과 = 반올림(60×qt×f×E÷Cm, 3) | 없음 | 값 ㎥/시간 | 다 읽힘 | +| GC000051 보행자 안전통로 | 없음 | QC000115(구분 고정 → 수량) · QC000116(구분 고정 → 값) | 비계공 = 표 | 노무 1 · 비율 1(사람 줄 노임 × 표 값 %) | 원/m | 다 읽힘 | + +- 6개 모두 왕복 같음 · Model(EasyCalc) 은 GC000051 의 비율을 「잠금」으로 봄(율이 숫자 아님) — 블록에서는 율 = ② 표 조회로 읽힘. +- 다짐은 두 로직(작업량 GF000114 → 금액 GF000249) — ⑤ 결과값 넘기기의 견본. + +### 1-3 마스터 전체 + +| 셈 | 수 | +| --- | --- | +| 식 자리(중간 · 수량 · 장비 요소 · 비율 · 결과) | 10,179 · 블록으로 읽힘 10,037(98.6%) | +| 로직 — 모든 식이 블록으로 읽힘 | **1,396 / 1,469(95.0%)** | +| 로직 — 모든 식이 문장 틀(칸 안 한 겹 묶음까지) | 1,299(88.4%) · 나머지 97개는 「여러 단계」 식이 섞임(단계 목록으로 펼쳐 보임 — 선택은 되나 김) | +| 다시 쓰기(해석 → 글) 같음 | 1,464(99.7%) · 다른 5개는 따옴표 · 빈칸 꼴 차이(글 갈아끼우기면 영향 없음) | +| 못 읽는 까닭 | 표 조건 칸에 계산(`최소(시공높이m, 7.2)` · `만약(…)` 등) 68 식 · 그리고/또는 69 식 · 로직 넘길 값에 계산 5 식 | +| 식 틀 갈래(식 수) | 값 5,362 · 곱나눔 2,655 · 묶음 사슬 1,231 · 조건 목록 515 · 여러 단계 168 · 할증 108 · 더하기 91 · 올림 43 · 최소최대 6 | +| 항목 종류(호표 줄) | 인력 3,462 · 로직(장비 · 일위대가) 1,075 · 재료 723 | + +### 1-4 EasyCalc 해석기 다시 쓰기 + +| 부품 | 쓰임 | 그대로? | +| --- | --- | --- | +| Model `toDoc · toRow` | ④ 항목 카드 · ⑤ 금액 · 왕복 100%(1,469) | 그대로 — 비율 율이 표 조회인 꼴만 블록 쪽에서 덧읽음 | +| Table `readFinds · writeFind · replaceFinds · literalValue` | ② 표 조회 읽기 · 조건 값 바꾸기 | 그대로 | +| Cond `readCond · writeRead · writeCond` | ③ 조건 목록 · 최소최대 · 올림 | 그대로(식 속 덩어리까지 · 줄 자체 조건 93%) | +| Api `calcLater · copyEasy · editEasy · newEasy · usesOf · listMachines · machineCall` | 예시값 계산 · 저장 · 장비 잇기 | 그대로 | +| Cards · Sheet | 오른쪽 결과 칸 | 그대로(읽기 · 고치기 둘 다) | +| 없는 것 | 사칙 사슬(곱나눔 · 할증 · 더하기 · 단계) 읽기 · 중간 값 그래프(어느 값이 어느 값을 씀) · 결과 식 로직 | LogicBuilder `Blocks` 가 새로 — 문법은 Cond 해석기와 같은 엔진 문법 | + +## 2. 표 칸 고치기 + +- **`/calc` 에 표 덮어쓰기 길 없음** — 몸 = `key · inputs · row · file · 고름` · 로직 줄만 메모리에서 바꿔 셈(`Store._swap`). 표는 언제나 파일 것. +- **현장(자체) 표 키 없음** — 표 ID 는 QF · QC(소요량) · CF · CC(계수)뿐 · `소요량_자체.json` 같은 자리 없음. M01 표 편집(칸 눌러 고침)은 `/save` 로 **표준 파일을 바로 고침** — 시험판은 부르지 않음. +- 서버가 가볍게 받을 수 있는 까닭 — `cm.load` 가 부를 때마다 파일을 새로 읽음(캐시 없음) → 메모리 사본에 표를 바꿔 끼워도 다른 요청에 안 번짐. + +최소로 필요한 것(⬜ 서버 · 폴더 밖): + +1. **`CalcBody.tables`** — `[{키, …표 통째}]` · `calc` · `text` 가 `_swap` 처럼 메모리 사본의 그 키 표를 바꿈(약 15줄) → 왼쪽 칸에서 고친 숫자가 저장 전 금액에 바로. +2. **현장 표** — 표 ID `QX`(소요량 자체) 하나 · 파일 `소요량_자체.json` · `master_keys.TABLES[("소요량", "자체")] = "QX"` · 길 `/table/copy · /table/edit · /table/delete`(현장 파일만 · `logic_*` 와 같은 틀 · 409 · 422) · 검사(`check_master` 틀 · 본문 대조에서 「자체」 표는 본문 대조 뺌). +3. 저장 차례 — 표준 로직을 연 채 표 칸을 고치면: 로직 복사 편집(GX) → 그 표 사본(QX) → GX 의 `찾기(QF…)` 머리를 `찾기(QX…)` 로 바꿈 → 한 번에 저장. **표준 표 · 로직은 안 고침**. + +정할 것 — 현장 표 사본을 로직마다 따로 둘지(간단 · 표가 늘어남) · 같은 표를 여러 현장 로직이 함께 쓸지(개정 한 번에 반영 · 사용처 관리 필요). + +## 3. 새로 만들기 — 단계 안내 6단계와 서버 길 + +| 단계 | 쓰는 길 | 있나 | +| --- | --- | --- | +| 1 절 찾기 | `GET /sections?book=산림품셈|건설품셈&q=` — 절마다 `tables` · `logics` 수 | 있음 · `logics = 0` 이고 `tables > 0` 인 절 = 새로 만들 자리 | +| 1 그 절의 표 | `GET /tables?group=소요량|계수§ion=13-4-1` | 있음 · 단 목록 머리에 `용도`(이어진 로직) 없음 — 「안 이어진 표」 거름은 절 수로 대신 · 꼭 필요하면 `_TABLE_HEADS` 에 `용도` 한 칸(⬜) | +| 2 칸 역할 | `GET /table`(통째) · `/table/options`(조건 칸 값) | 있음 — 조건 · 값칸 · 범위규칙 · 주 | +| 3 결과 단위 | 표 `기준`(「1㎡」) | 있음 | +| 4 계산 | 화면(Cond 틀 · 할증 · 곱나눔) | 서버 길 필요 없음 | +| 5 항목 연결 | `GET /pick?kind=job&q=석공` · `/materials` · Api `listMachines · machineCall` · `/logics` | 있음 — 값 칸 이름 = 직종 이름이면 저절로 | +| 6 이름 · 저장 | `POST /logic/new`(GX · 원문번호 · 출처 「산림품셈 13-4-1」 꼴 허용) · 저장 전 `/calc`(`key ""` + `row`) | 있음 | + +- 표가 아직 없는 절(엑셀 붙여넣기)은 2절 현장 표 길(⬜)이 있어야 저장됨 — 시험판 1차는 「표가 있는 절」만. +- 안 이어진 표 1,010개(용도.로직키 빈 것 · 제안서 1,018 과 8 차이 — 그 사이 이어짐). + +## 4. 조사로 더할 것 셋 + +| 것 | 지금 API | 판정 | +| --- | --- | --- | +| 표 조회 해당 없음 / 중복 | `/calc` 줄이 비고 `까닭` 「표 QF…에 뒷길이 25 · 돌 견치돌 인 줄이 없음」 / 「… 인 줄이 2개」(엔진 `find` → `NoRow`) | **지금 됨** — 글로 갈라 읽음(약함) · 단단히 하려면 까닭에 `종류: 없음|중복` 칸(⬜ 작음) | +| 빈틈 검사(입력값 조합 중 표에 없는 것) | 길 없음 · `/calc` 를 조합마다 부르면 됨(메쌓기 7×4×2 = 56번 · 빈틈 16) — 느림 | **서버 길 하나(⬜)** `/table/gaps?key=` — `master_auto._uses`(어느 입력이 어느 조건으로 가나) + `mf.find` 로 조합을 돌려 「없음 · 중복」 목록 · 판정이 엔진과 같아야 해서 화면 짝 구현(범위규칙 네 가지)보다 서버 단독이 맞음 | +| 기대값 시험표 | `/calc` 를 줄마다(입력값 → `lines[i].수량` · 결과값) 부르면 됨 | **지금 됨**(계산) · 저장 자리 정할 것 — 현장 로직 줄의 새 칸(`시험표` · `check_master` 가 로직의 모르는 칸을 막지는 않음 · `_clean` 이 칸 차례 밖 칸을 뒤에 붙여 둠) 또는 따로 파일 · 시험판 1차는 저장 없이 화면에만 | + +## 5. 표준 · 현장 규칙 (화면 한 줄) + +- 표준(품셈 원문 · 로직 GF · GC · 표 QF · QC · CF · CC) = 읽기 · 예시값 계산 · 복사 편집만. +- 현장(로직 GX · 표 QX⬜) = 고치기 · 지우기 · `로직_자체.json`(+ `소요량_자체.json`⬜) 만 씀. +- 「품셈이 개정돼도 현장 일위대가는 안 바뀜」 — 저장 화면 머리에 한 줄. + +## 6. 화면 세 칸과 부품 + +``` +┌ 원문(Source) ─────────┐┌ 산출근거(Steps) ───────────────┐┌ 결과(Result) ──────┐ +│ 표 머리 역할 칩 ││ ① 입력값 선택 목록 칩 ││ EasyCalc Cards │ +│ 조건 · 고정 · 결과 · 주 ││ ② 표 조회 조건 칸 ← 입력값/고정 ││ (읽기 · 고치기) │ +│ 걸린 줄 노랑 ││ · 해당 없음/중복 표시 ││ 큰 합계 · 일위대가표 │ +│ 칸 눌러 고침(사본만) ││ ③ 계산 Cond 문장 · 할증 · 곱나눔 ││ 예시값 띠 │ +│ 빈틈 · 시험표 단추 ││ ④ 항목 연결 노무 · 재료 · 장비 … ││ 기대값 시험표 │ +│ ││ ⑤ 결과값 금액 / 값 · 넘기기 ││ │ +└─────────────────────────┘└────────────────────────────────┘└────────────────────┘ +처음 만들 때 = 가운데 칸이 단계 안내(물음 하나씩) · 답할 때마다 한 줄씩 채워짐 +``` + +## 7. 파일 나누기 초안 (`M01_MasterData/LogicBuilder/` · 파일마다 700줄 안 · 글 [한, 영] 짝은 파일 머리) + +| 파일 | 하는 일 | DOM | 맡을 창(안) | +| --- | --- | --- | --- | +| `M01_LB_Blocks.ts` | 로직 줄 ↔ 블록(①~⑤) 읽기 · 글 갈아끼우기 쓰기 · 사칙 사슬 · 중간 값 그래프 · 결과 식 — EasyCalc Model · Table · Cond 해석기를 가져다 씀 | 없음 | A(Model 쓴 창) | +| `M01_LB_Source.ts` + css | 왼쪽 원문 — 표(`/table`) · 칸 역할 칩 · 주 · 걸린 줄(`/text` 표 조각 `참조.줄`) · 칸 고치기(메모리 사본) · 해당 없음/중복/빈틈 표시 | 있음 | B(Table 쓴 창) | +| `M01_LB_Steps.ts` + css | 가운데 산출근거 ①~⑤ 카드 — 모두 선택 · Cond 문장 · 할증 · 곱나눔 · 항목 연결(Pick 다시 씀) | 있음 | C(Cond · Cards 쓴 창) | +| `M01_LB_Wizard.ts` + css | 단계 안내 6단계 — 절 찾기 → 표 · 칸 역할 → 단위 → 계산 → 항목 → 이름 · 답을 Blocks 에 쌓음 | 있음 | D | +| `M01_LB_Check.ts` | 기대값 시험표 · 빈틈 결과 정리 · `/calc` 여러 번(최신만) | 거의 없음 | D 또는 B | +| `M01_LB_Entry.ts` + css | 단추(「로직 만들기(시험)」 · 쉬운 단가산출 옆) · 세 칸 틀 · 오른쪽 결과(EasyCalc Cards · Sheet 붙임) · 열기 · 복사 편집 · 저장(EasyCalc Api) · 실행 취소 · 저장 안 함 표시 | 있음 | E(Entry 잇기 창) | +| 서버(폴더 밖 · ⬜) | `CalcBody.tables` · 현장 표 QX 길 셋 · `/table/gaps` · (작음) 까닭 종류 칸 · `/tables` 머리에 `용도` | — | 브레인 지명 한 창 | + +### 7-1 서로 맞출 모양 (안) + +```ts +// Blocks (A) +export type LbRef = + | { k: "input"; name: string } // ① 입력값 + | { k: "fixed"; value: number | string } // 고정값 + | { k: "lookup"; id: string } // ② 표 조회 결과 + | { k: "calc"; name: string } // ③ 이름 붙은 계산값(중간) + | { k: "logic"; key: string; field: string | null } // 다른 로직 결과값(.노무비 등) + | { k: "cost"; name: "노무비" | "재료비" | "경비" | "사람줄합" }; +export interface LbLookup { + id: string; // 화면 번호 · 저장 안 함 + table: string; // QF000421 · 현장 사본이면 QX… + conds: { col: string; from: LbRef | { k: "expr"; text: string } }[]; + col: string; // 결과 칸 + at: { part: "중간" | "수량" | "비율" | "결과" | "요소"; index: number; start: number; end: number }[]; +} +export type LbCalcKind = "값" | "곱나눔" | "할증" | "더하기" | "조건목록" | "최소최대" | "올림" | "단계"; +export interface LbCalc { + name: string | null; // 중간 이름 · 수량 자리면 null + kind: LbCalcKind; + parts: LbRef[]; // 곱나눔 · 더하기 · 할증(바탕 · 율) + ops?: ("*" | "/" | "+" | "-")[]; + cond?: CondRead; // 조건목록 · 최소최대 · 올림 — Cond 것 그대로 + at: { part: "중간" | "수량" | "비율" | "결과"; index: number }; + text: string; // 원래 글(안 고치면 그대로 씀) +} +export interface LbLogic { + doc: EasyDoc; // ④ 항목 · ⑤ 금액 — Model 것 그대로 + inputs: LogicInput[]; // ① + lookups: LbLookup[]; // ② + calcs: LbCalc[]; // ③ + result: { kind: "금액"; unit: string } | { kind: "값"; unit: string; calc: LbCalc }; // ⑤ + blocked: string[]; // 못 읽은 까닭(쉬운 말) — 비면 다 읽힘 +} +readLogic(row: LogicRow, meta): LbLogic +writeLogic(lb: LbLogic): LogicRow // 고친 조각 자리만 글 갈아끼우기 +setCond(lb, lookupId, col, from): LbLogic · setCalc(lb, calc, patch) · setLookupTable(lb, id, newKey) + +// Source (B) +export interface TableEdit { table: string; row: number; col: string; value: number | string | [number | null, number | null] } +renderSource(host, lb, tables: Record, hits: Record, on: { edit(e: TableEdit): void; role(table: string, col: string, role: "조건" | "고정" | "결과"): void }) + +// Steps (C) +renderSteps(host, lb, ans: CalcAnswer | null, on: { change(lb: LbLogic): void }) + +// Wizard (D) +openWizard(): Promise +// Check (D|B) +runExpect(lb, rows: { inputs: Record; expect: Record }[]): Promise<{ ok: boolean; got: Record }[]> +gaps(lb): Promise<{ table: string; conds: Record; kind: "없음" | "중복" }[]> // ⬜ /table/gaps + +// Entry (E) +createLogicBuilderButton(): HTMLButtonElement +// 예시값 계산 = EasyCalc Api calcLater(row, trial, …) + 고친 표(⬜ CalcBody.tables) +``` + +### 7-2 차례(제안서 「나눠 만드는 차례」와 맞춤) + +1. **확인** — Blocks(A) + Entry 틀(E) + Source 읽기(B) + Steps 읽기(C) — 6개 로직을 세 칸으로 열어 읽기만 · 서버 변경 없이 됨. +2. **고치기** — Steps 선택 고치기(C) · Source 칸 고치기(B · ⬜ `CalcBody.tables` · 현장 표 QX) · 복사 편집 저장(E). +3. **만들기** — Wizard(D) — 표가 있는 절부터. +4. **넘기기 · 검사** — 결과값 넘기기 · 장비 잇기(C) · Check(D · ⬜ `/table/gaps`). + +## 8. ⬜ 브레인 답 필요 + +1. 서버 변경(폴더 밖) 허락 — `CalcBody.tables`(표 덮어 시험 계산) · 현장 표 QX(파일 · 키 · 길 셋 · 검사) · `/table/gaps` · (작음) 까닭 종류 칸 · `/tables` 머리 `용도`. 없으면 시험판은 「표 칸 고치기」 · 「빈틈 검사」를 뺀 범위. +2. 현장 표 사본 — 로직마다 따로 / 여러 현장 로직이 함께. +3. 기대값 시험표 저장 자리 — 현장 로직 줄 새 칸 / 따로 파일 / 화면에만(1차). diff --git a/docs/raw/M01_쉬운단가/로직판_A_정함.md b/docs/raw/M01_쉬운단가/로직판_A_정함.md new file mode 100644 index 000000000..b5f7a099b --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_A_정함.md @@ -0,0 +1,40 @@ +# 로직 만들기 판 시험판 A — 정함 (PLAN 35-2 · 브레인 · 10-01) + +근거 = 같은 폴더 `로직판_A_설계초안.md`(sub7 · 블록 구조 · 파일 나누기 7절 · 맞출 모양 7-1) · `로직판_엑셀로직_지도.md`(sub4 · A 대 B) · `로직판_조사_A · B`(인터넷) · 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY. + +## 목적 (모든 창이 이것으로 판단) + +사용자가 브레인 · 코딩 없이 품셈 원문을 보며 단가산출 로직을 직접 ① 원문 대조로 확인 ② 품셈 숫자가 바뀌면 직접 고침(예: 인원 2~3 → 3~4) ③ 혼자 새로 만듦(초등학생도) ④ 많은 수를 검증 ⑤ 서버 엔진과 같은 값. + +## 정함 + +1. 갈래 = A 세 칸 판(왼쪽 원문 표 · 가운데 산출근거 · 오른쪽 결과). B 엑셀 시트형은 따로 안 만듦(단가 붙이기 · 표 사본 · 엔진 둘에서 막힘) — 엑셀 부품(격자 · 단계 값 · 선택 목록)만 빌림. +2. 복수 시험 = 가운데 칸 보기 둘 — **문장 보기**(Steps) · **격자 보기**(Grid · 엑셀처럼 줄 · 칸 · 칸마다 선택 · 값 · 식 없음) · 위 단추로 바꿔 봄 · 같은 LbLogic · 같은 on.change. +3. 폴더 `M01_MasterData/LogicBuilder/` · 단추 「로직 만들기(시험)」 = 「쉬운 단가산출(시험)」 옆 · 폴더째 지우면 끝. EasyCalc(유지 확정) 파일은 가져다 쓰기만 · 안 고침. +4. 서버 — 기존 서버 파일은 안 고침. 표 칸 고친 값으로 시험 계산은 폴더 안 새 파이썬 모듈(`M01_LB_Calc.py` · `POST /api/m01/lb/calc {row, inputs, tables}`)이 엔진을 같은 방식으로 부르되 전역 캐시를 바꾸지 않고 사본으로 셈 · 등록은 한 줄. 엔진에 고리가 꼭 필요하면 기본값 있는 선택 인자 하나만(지워도 안 깨짐) — 그 경우 보고에 적음. +5. 표 칸 고치기 = 메모리 사본 + 예시값 즉시 재계산 + 영향 보기(그 표를 쓰는 로직 · `용도.로직키`) · **저장 안 함**(표준 표 고치기 · 현장 표 사본은 사용자 결정 뒤) · 화면에 「표 고침은 미리 보기 — 저장은 결정 뒤」 한 줄. +6. 로직 저장 = 현장 일위대가(`/logic/new · edit` · GX)만 · 표준은 「복사 편집」 뒤. +7. 빈틈 검사 = 브라우저에서(입력값 선택 목록 조합 × 표 줄) · 해당 없음 / 중복 = `/calc` 까닭 + 표 줄 대조 · 기대값 시험표 = 화면 초안(sessionStorage)만 · 저장 안 함. +8. 오른쪽 결과 = 큰 합계 + 일위대가표(EasyCalc Sheet renderSheet) + LB 자체 예시값 띠(새 용어) — EasyCalc Cards 는 안 붙임(옛 낱말). +9. 용어 = 제안서 2판 「용어 — 단어로」 제안 그대로: 입력값 · 고정값 · 결과(값) · 조건/고정/결과(표 칸 역할) · 선택 · 표 조회 · 계산 · 산출근거 · 항목 연결 · 노무/재료/장비/일위대가/비율/기타 · 노무비/재료비/경비 · 조건/그 외 · 무관 · 해당 없음/중복 · 표준/현장 · 복사 편집 · 자동 · 직접 입력 · 조건 편집 · 예시값 · 단계 안내 · 변경/실행 취소/삭제/추가 · 규격/사용처/출처 · 일위대가표 · 적용 품셈. +10. 시험 로직 6개 — GF000208 암거이음받침 채움재 · GF000219 돌쌓기 메쌓기(인력) · GF000025 떼붙임(재배잔디) · GF000249 다짐(플레이트콤펙터) · GF000133 덤프트럭 시간당 작업량 · GC000051 보행자 안전통로 — 시작 화면에 이 6개를 먼저 보이고 찾기로 다른 것도 엶. + +## 파일 · 창 + +| 파일 | 창 | 하는 일 | +| --- | --- | --- | +| `M01_LB_Blocks.ts` | sub7 | 로직 ↔ 블록(①~⑤) · 설계초안 7-1 타입 · 먼저 타입 올림 | +| `M01_LB_Calc.py` + 등록 한 줄 · `M01_LB_Api.ts` | sub4 | 표 덮어 시험 계산 서버 길 · 화면 부르기(calcLater 꼴 · 최신만) · 영향 보기(표 → 로직) | +| `M01_LB_Source.ts` + css · `M01_LB_Check.ts` | sub8 | 왼쪽 원문(격자 · 칸 역할 · 주 · 걸린 줄 · 칸 고치기 · 해당 없음/중복/빈틈) · 빈틈 · 기대값 시험표 | +| `M01_LB_Steps.ts` + css | sub2 | 가운데 문장 보기 | +| `M01_LB_Grid.ts` + css | sub5 | 가운데 격자 보기 | +| `M01_LB_Wizard.ts` + css | sub3 | 단계 안내 6단계 | +| `M01_LB_Entry.ts` + css | sub1 | 단추 · 세 칸 틀 · 보기 바꿈 · 열기 · 복사 편집 · 저장 · 실행 취소 · 오른쪽 결과 · 잇기 · 끝 검증(목적 다섯) | + +## 맞출 모양 + +설계초안 7-1 그대로 + 더함: +- Grid: `renderGrid(host, lb, ans, on: { change(lb): void })` — Steps 와 같은 꼴. +- Source: `renderSource(host, lb, tables, hits, on: { edit(e: TableEdit); role(table, col, role) })` — tables = 표 키 → 표 사본(고친 값 포함). +- Api(sub4): `lbCalcLater(row, inputs, tables, onDone(ans | null, plain | null))` · `tableUsers(tableKey): Promise<{ key; 이름 }[]>` · `fetchLbTable(key)`. +- Check: `gaps(lb, tables)` 는 브라우저 셈 · `runExpect(lb, tables, rows)`. diff --git a/docs/raw/M01_쉬운단가/로직판_엑셀로직_지도.md b/docs/raw/M01_쉬운단가/로직판_엑셀로직_지도.md new file mode 100644 index 000000000..554843ff2 --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_엑셀로직_지도.md @@ -0,0 +1,161 @@ +# 로직판 조사 — 「엑셀(스프레드시트)로 만드는 로직」 지도 (35-2 · sub4) + +조사만 · 코드 · git · docs 안 건드림 · 근거 = sub_laptop_4 작업 트리(2026-10-01 · 안 받은 dev 28 커밋은 스프레드시트 파일을 안 건드림 — `git log HEAD..origin/dev` 로 확인). +`ss/` = `A00_Common/spreadsheet/`. + +## 결론 셋 + +1. **시트가 계산 규칙으로 쓰이는 곳은 한 곳뿐** — M02 구조물 「면적산출 · 재료산출」 통합문서. 칸 → 알약(입력값 키) 연결까지는 저장되지만 **그다음(일위대가 입력값 · 금액)으로 이어지는 코드가 없음** — 단가 계산은 `면적.json` · `재료.json` · `계산값` 을 읽지 않음. B08 · B09 는 빈 화면. +2. **자체 엔진은 엑셀 기본기를 거의 갖춤** — IF · IFS · IFERROR · VLOOKUP · XLOOKUP · INDEX/MATCH · SUMIFS · MINIFS · ROUND 계열 · 이름 정의 · 시트 사이 참조 · 선택 목록(데이터 유효성) · 계산 단계 보기 · 선행/종속 화살표. 서버는 저장 때 **같은 TS 엔진을 Node 로 다시 셈**(계산 자리 ②). 없는 것 — 다른 통합문서 참조 · LAMBDA/LET · INDIRECT/OFFSET · 마스터(노임 · 자재 · 기계 · 다른 로직)를 읽는 함수. +3. **B(시트로 로직) 는 「표 찾기 · 조건 · 할증 · 비율」 은 시트로 옮길 수 있으나 「단가 붙이기(노임 · 자재 검색 · 기계경비 로직 부르기)」 와 「여러 로직이 한 원문 표를 함께 씀」 에서 막힘** — 시트 엔진 고침 + 새 연결 없이는 로직 ↔ 시트 왕복이 안 되고, 엔진이 둘(파이썬 정본 · TS 시트)이 되어 같은 값 보장이 새 짐. **판단: A(세 칸 판)를 본 갈래로 · B 는 「원문 표 보기 · 계산 단계 보기」 같은 부품만 빌려 씀.** B 시험판을 만들면 「제한된 시트(찾기 · 단가 함수를 엔진에 더함)」 꼴로 범위를 좁혀야 함. + +--- + +## 1. 스프레드시트가 로직(계산 규칙)으로 쓰이는 곳 + +| # | 자리 | 하는 일 | 핵심 코드 | 저장 | 화면 | +| --- | --- | --- | --- | --- | --- | +| 1 | M02 구조물 면적산출 | 집계표 열(꼴 「수」) 하나마다 면적 셈 시트 | `M02_MasterTemplete_Store.py:66-72`(PARTS · `SHEETS = ("area","material")`) · `:282-301`(빈 통합문서) · `:169-170`(저장 때 서버 셈) | 층: `resources/master_template/area/<열id>.json` · 회사 · 개인 층도 같은 자리 · 프로젝트: `templates/structures/<구조물id>/면적.json`(옛 `templates/items/…`) | 큰 편집기 `M02_MasterTemplete_UI_Main.ts:273-361`(수식 입력 줄 · 주소 칸 = `ss/spreadsheet_editor.ts`) · 카드 미리보기 읽기 `M02_MasterTemplete_ItemCards.ts:314-317` · 관리자 카드 편집 `M02_MasterTemplete_Structure.ts:305-359,417-422` + 저장 단추 `M02_MasterTemplete_CardShell.ts:69-120` · 프로젝트 사본 편집 `ui_template/ui_template_item_picker_edit.ts:103-129` | +| 2 | M02 재료산출 | 같은 열의 재료 수량 시트 | 위와 같음(`material/<열id>.json` · 프로젝트 `재료.json`) | 연결 칸에 `자재: {코드: MT…}` 가 더 붙음 | 위와 같음 + 자재 찾기 알약 | +| 3 | 알약 줄(칸 ↔ 입력값 연결) | 시트 아래 알약 막대 · 알약을 칸에 놓으면 「이 칸 값 = 이 입력값」 | `ss/spreadsheet_links.ts:1-9,124-381` · 늦게 불러옴 `ss/spreadsheet.ts:151-163` | `Sheet.연결: [{키, 이름?, 단위?, 칸: "O6", 자재?}]`(`ss/spreadsheet_types.ts:200,211-219`) | 알약 = 조합 물량 `GET /combos/key/{key}/inputs`(`M02_MasterTemplete_Router_Combos.py:99-123` ← `M01_MasterData_Store_Combo.py:168-191`, 키 「로직이름|단위」) + 입력 알약 `GET /layers/{layer}/items/{key}/pills`(`M02_MasterTemplete_Router_Items.py:169` ← `M02_MasterTemplete_Inputs.py:190-218`, 키 「입력|로직|입력」) | +| 4 | 입력 자리 정하기(입력연결) | 일위대가 입력값마다 받는 자리 = 면적 · 재료 · 선택 | `M02_MasterTemplete_Inputs.ts:4-10` · `M02_MasterTemplete_Inputs.py:8-10,144-184` | `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` · 프로젝트 `입력연결.json` | 「선택 입력」 카드 | +| — | (다른 엔진) 구조물집계표 표 수식 | 집계표 칸 수식(설계 값 채우기) | `ui_template/sheet/ui_template_sheet_recalc.ts` · 서버 `common_util/common_util_sheet_recalc.py:30-40` ← `M02_Table_Fill.py:654-660` | 표 템플릿 | 집계표 화면 — **이 갈래 엔진은 스프레드시트 엔진과 따로임** | +| — | 자재조합 | 자재 목록뿐 · 수량 · 단가 없음(「재료산출 몫」) | `M02_MasterTemplete_Matset.py:1-4` | `자재조합.json` | M02 자재 조합 | +| — | B08 수량 · B09 견적 | 빈 화면(다시 짓는 중) | `B08_Quantity/B08_Quantity_UI_Page.ts:3-6` · `B09_Estimation/B09_Estimation_UI_Shell.ts:3-6` | — | — | + +실제 프로젝트 사본 한 벌(`storage/1/3/…/templates/items/system_none_SS000002/면적.json`): + +```json +"칸": {"M6": {"값": 2}, "N6": {"값": 3}, "O6": {"식": "N6*M6"}, "M8": {"식": "O6/M6*0.2"}}, +"연결": [{"키": "GF000222", "이름": "돌쌓기 찰쌓기", "칸": "O6", "단위": "㎡"}], +"계산값": {"s1": {"O6": {"수": "6"}, "M8": {"수": "0.6"}}} +``` + +### 1-1. 값 흐름 — 어디서 끊기나 + +1. 시트 칸(수식) → 알약 키 연결(`Sheet.연결`) — **저장됨**. +2. 브라우저 `getLinkedValues()`(`ss/spreadsheet.ts:275-283`) — 부르는 곳은 시험 하네스뿐(`resources/tester/spreadsheet/harness_links.ts:3`). +3. 프로젝트 단가 계산 `POST /api/projects/{id}/items/{folder}/calc` → `M02_Item_Copy.py:326-337` → `M02_Item_Prices.calc`(`:211`) — 읽는 것 = `입력값.json` · `고른자재.json` · `단가.json` · `일위대가.json` 뿐. 면적 · 재료 자리 입력은 `missing_inputs`(`:195-208`)에서 「입력 없음」 으로 멈춤 · 값 카드는 「선택」 자리 입력만 받음(`M02_MasterTemplete_Inputs.ts:291-295`). + - 주의: `M02_Item_Prices.py:81` 의 `연결` 은 품셈재료 연결(마스터 줄 칸)이지 시트 칸 연결이 아님. + +→ **「시트 칸 → 일위대가 입력값 → 금액」 은 아직 안 이어짐.** 시트는 지금 「구조물 물량 계산기」 이지 단가산출 로직이 아님. + +### 1-2. 계약 어긋남(덤) + +- 형식 · 실제 파일은 `시트[i].연결`(시트마다) · `resources/tester/spreadsheet/test_spreadsheet_store.py:54,92,111` 와 `M02_MasterTemplete_Store.py:158,306` 설명은 문서 머리 `연결` 이라 적음. +- 데이터 유효성 규칙이 행 · 열 넣기/지우기 때 안 옮겨짐(`remapValidationRanges` 부르는 곳 없음) · 붙여넣기 · 채우기는 검사 안 거침 · 서버는 유효성 검사 안 함. + +--- + +## 2. 자체 스프레드시트 엔진이 되는 것 + +### 2-1. 함수 (`ss/spreadsheet_functions.ts:16-21` 에서 네 파일을 합침 · 모르는 이름 `#NAME?` · 인자 수는 풀이 때 검사) + +| 갈래 | 있음 (파일:줄) | +| --- | --- | +| 논리 | IF(misc 160) · AND 170 · OR 179 · NOT 188 · IFERROR 196 · IFS(lookup 471) · SWITCH 482 | +| 찾기 · 참조 | VLOOKUP(lookup 132) · HLOOKUP 166 · INDEX 200 · MATCH 233 · CHOOSE 269 · XLOOKUP 496 | +| 조건 집계 | COUNT · COUNTA · COUNTIF · SUMIF 319 · SUMIFS 345 · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS | +| 수학 · 끝수 | SUM 189 · PRODUCT · SUMPRODUCT 205 · ROUND 232 · ROUNDUP 243 · ROUNDDOWN 254 · INT 265 · TRUNC · CEILING 285 · FLOOR 300 · MIN 393 · MAX 401 · MROUND · ABS · MOD · POWER · SQRT · 삼각 · LOG · EXP | +| 글 | CONCATENATE · TEXT · TEXTJOIN · LEFT/RIGHT/MID · SUBSTITUTE · FIND/SEARCH … | +| 정보 · 날짜 · 통계 | ISNUMBER · ISBLANK · ISERROR · NA · DATE 계열 · MEDIAN · STDEV · RANK · LARGE/SMALL … | +| **없음** | XMATCH · LET · LAMBDA · INDIRECT · OFFSET · CONCAT · IFNA · ROW/COLUMN · 다른 통합문서 참조 · 마스터 자료를 읽는 함수 | + +연산 — `- + % ^ * / & = <> < <= > >=` · 범위 `A1:B5` `A:A` `$` · 합집합 `(A1,B1)` · 교차(빈칸) · 계산된 범위는 없음(`ss/spreadsheet_parser.ts:4-5,20-24,125,226-269`). 오류값에 엑셀에 없는 `#CYCLE!`. + +### 2-2. 이름 정의 · 시트 사이 참조 · 선택 목록 + +- **이름 정의** — `Sheet.이름정의: [{이름, 범위}]`(`ss/spreadsheet_types.ts:193,553-556`) · 범위만(상수 · 식 이름 없음) · 제 시트 먼저 → 다른 시트 · 대소문자 무시(`ss/spreadsheet_names.ts:47-60` · `ss/spreadsheet_graph.ts:87-113,150`) · 이름 상자 · 이름 관리자(`ss/spreadsheet_editor.ts:556-568` · `ss/spreadsheet_data_menu.ts:183`) — 계산에 실제로 붙음(형식 파일의 「안 이음」 설명은 낡음). +- **시트 사이 참조** — `Sheet2!A1` · `'시트 이름'!A1:B3` 됨(`ss/spreadsheet_parser.ts:21,39-42` · `ss/spreadsheet_eval.ts:252-254`) · 이름 바꾸면 다른 수식도 고침 · 3D(`S1:S3!`) · 다른 통합문서(`[Book]`) 없음. +- **선택 목록(데이터 유효성)** — `Sheet.유효성: [{범위, 규칙: {종류: "목록", 값들} | {종류: "수범위", 아래, 위}, 어기면: 막음 | 경고}]`(`ss/spreadsheet_types.ts:540-550`) · 칸 옆 ▼ 목록(`ss/spreadsheet_data_menu.ts:225-255`) · 들어갈 때 검사(`ss/spreadsheet_editor.ts:420-424`). +- **확인 도구** — 계산 단계 보기(`ss/spreadsheet_eval_steps.ts` — 엑셀 「수식 계산」 처럼 한 조각씩 값으로 바꿈 · IF 는 조건부터) · 선행/종속 화살표(`ss/spreadsheet_trace.ts`) · 오류 검사 ⚠(`ss/spreadsheet_errcheck.ts`) · 수식 칸 참조 색 테두리. + +### 2-3. 계산 · 크기 + +- 의존 그래프 · 바뀐 칸과 딸린 칸만 다시 셈 · 순환은 그 칸들만 `#CYCLE!`(`ss/spreadsheet_graph.ts:3-5,131-136,204-227,277-325,350-378`). +- 수 = BigInt 분수(`@ui/sheet/ui_template_sheet_frac` — 집계표와 끝수 한 벌 · `ss/spreadsheet_types.ts:7-9`) · 파이썬 단가 엔진은 `Decimal` — **같은 식이라도 두 엔진의 끝자리가 다를 수 있음**(대조 시험 필요). +- 파일 모양 — `Workbook {종류: "통합문서", 판, 열, 서식[], 시트[{id, 이름, 칸: {A1: {값 | 식, 서식}}, 병합, 열, 행, 유효성, 이름정의, 연결 …}], 계산값}`(`ss/spreadsheet_types.ts:95-102,198-242`) · 수식은 「=」 없이 · `계산값` 은 서버만 씀(브라우저 것은 버림). +- **128 KB 는 시트 파일 한도가 아님** — 브라우저가 M02 시트를 열 때 받는 코드 조각의 gzip 예산(엔진 48 + 화면 72 + css 8 KB · `ss/spreadsheet_types.ts:15-16` · 재는 곳 `resources/tester/spreadsheet/test_spreadsheet_size.py:19,41-45`). 시트 파일 크기 한도는 없음(행 1,048,576 · 열 16,384 만). +- 더할 수 있는 것(예산 안) — 함수 하나는 수십 줄 · 수 KB 미만이라 `찾기(표, 조건…)` · `단가(키)` 같은 자체 함수 몇 개는 엔진 48 KB 안에 들어갈 여지가 큼(지금 빌드 크기는 이번 조사에서 안 잼 — F 시험으로 잼). 단 `spreadsheet_types.ts` 머리 규칙상 엔진 · 계약은 「0 계약 주인」 만 고침 · 외부 라이브러리 0. + +--- + +## 3. 시트 계산을 누가 하나 + +| 때 | 누가 | 근거 | +| --- | --- | --- | +| 화면에서 고칠 때 | 브라우저 TS 엔진(보이기용) | `ss/spreadsheet.ts:199-214,269` | +| [저장](마스터 · 회사 · 개인 층) | **서버 — 파이썬이 같은 TS 엔진 Node 묶음을 부름** · 브라우저 `계산값` 은 버리고 서버 값으로 | `common_util/common_util_spreadsheet.py:17-57`(묶음 `config/spreadsheet_node/…` · 낡으면 `npm run build:spreadsheet` 로 다시 묶음 `:27-34`) · `common_util/common_util_node_bundle.py:60-93` · 부르는 곳 `M02_MasterTemplete_Store.py:169-170,462` · `M02_MasterTemplete_Router_Layers.py:318-324` | +| 프로젝트 사본 저장 | 서버 Node | `M02_Project_Items_Router.py:139-155` → `M02_Item_Copy.py:200-201` | +| 층 안 열 사이 복사 · 개인 층 저장 · 항목 복사 | 서버 Node | `M02_MasterTemplete_Store.py:316,364` · `M02_Item_Personal.py:107` · `M02_MasterTemplete_Items.py:155` | +| 프로젝트로 복사(templates/apply · seed · reset · 스냅숏) | **다시 안 셈** — 바이트 그대로(따라온 `계산값` 을 믿음) | `M02_MasterTemplete_Router_Layers.py:412-454` · `M02_Template_Layers.py:305,345,368` · `M02_Item_Snapshot.py:97-102,180-210` | +| B08 · B09 | 없음(빈 화면) | 위 1절 | +| 실패 | Node 실패 = `RecalcError` → 503 · 저장 막힘 · 묶기 300 초 · 돌리기 600 초 한도 | `common_util_node_bundle.py:27-28` | + +CLAUDE.md 5장과 대조: + +- 시트 자체는 **② 「TS 를 서버 Node 로」** 에 맞음(화면과 같은 코드 · 저장 때 서버가 다시 셈 · 브라우저 값 안 받아 적음). +- 프로젝트 복사 때 안 다시 셈 — 「초기값은 서버가 한 번 계산해 영구저장」 과는 맞으나, 마스터 쪽 단가가 바뀌어도 시트 `계산값` 은 그대로 따라감(시트는 마스터를 안 읽으니 지금은 문제 없음). +- 단가산출 로직은 **③ 서버 단독(파이썬 `master_formula`)** · 시트로 로직을 만들면 같은 값을 두 엔진(파이썬 정본 · TS 시트)이 내게 되어 「코드는 한 벌」 에 어긋남 → B 를 하려면 ① 「짝 + 거울 시험」 이거나, 시트는 편집기일 뿐 저장 때 로직 형식으로 바꿔 파이썬이 셈(시트 → 로직 변환이 필수). + +--- + +## 4. B 갈래 — 단가산출 로직 한 벌을 시트로 + +꼴(가정): 시트 ① 원문 표(표마다 한 시트) · ② 입력값 칸(선택 목록) · ③ 산출근거 칸(이름 붙은 수식) · ④ 항목 칸(종류 · 이름 · 단위 · 수량 · 단가 · 금액 · 비목). + +### 4-1. 로직 → 시트 (지금 형식 하나하나) + +| 로직 구성 | 시트로 | 됨? | 막힘 · 까닭 | +| --- | --- | --- | --- | +| 입력 · 고르기 | 입력 칸 + 선택 목록 | 됨 | 서버가 유효성 검사 안 함 · 붙여넣기는 검사 안 거침 | +| 입력 · 범위 | 수범위 유효성 | 됨 | 같음 | +| 입력 · 수 | 빈 칸 | 됨 | — | +| 중간(이름 = 식) | 이름 정의한 칸 + 수식 | 됨 | 이름은 범위만 · 칸 이름이 한글이면 엔진 이름 규칙 확인 필요 | +| 찾기 · 같음 조건 | `SUMIFS`/`MAXIFS`(수 값) 또는 `XLOOKUP`(묶은 열쇠 열) | 반쯤 | 「맞는 줄 없음 · 둘 이상」 을 엔진은 멈춤으로 알리는데 SUMIFS 는 0 을 조용히 냄 → 틀린 금액이 숨음 · 「빈 조건 칸 = 무관」 은 조건마다 `(칸="" 또는 칸=값)` 을 더 써야 함 | +| 찾기 · 범위 조건 | 아래 · 위 두 열 + MINIFS 등 | 어렵 | 범위규칙 다섯 가지(초과·이하 · 이상·미만 + 「마지막 칸은 이하 포함」 · 이하 = 위 끝 가장 작은 줄 · 이상·이하)(`master_formula.py:331-384`)를 수식마다 손으로 흉내 | +| 찾기 · 값 칸이 식 글(「L/V×2×60」) · 범위 값 [아래, 위] | 열 둘로 펴기 · 식 글은 따로 | 어렵 | 표 모양을 시트로 바꾸는 규칙이 새로 필요 | +| 만약 · 비교 · 그리고/또는 | IF · AND · OR | 됨 | — | +| 최소 · 최대 · 올림 · 버림 · 반올림(자리) | MIN · MAX · ROUNDUP · ROUNDDOWN · ROUND | 됨 | 수 표현(분수 대 Decimal) 끝자리 대조 필요 | +| 할증 `(1 + 율/100)` · % | 수식 · `%` | 됨 | — | +| 호표 수량 | 항목 칸 수량 = 수식 | 됨 | — | +| **호표 단가 — 인력 노임(LB)** | 단가 칸을 마스터에서 채워야 함 | **막힘** | 시트에 마스터를 읽는 함수 없음 · 다른 통합문서 참조 없음 → 서버가 칸을 채워 주는 새 연결(또는 `단가()` 함수) 필요 | +| **호표 단가 — 재료 검색어 고르기** | 〃 | **막힘** | 자동값 차례 · 단위 환산 · 지역 · 계약종별(`master_pick`)을 시트가 못 함 | +| **호표 — 기계(기계경비 로직 부름)** · 다른 로직 부름(1,075 줄 · 506 로직) | 〃 | **막힘** | 인자를 넘겨 다른 로직을 부르는 길 없음(LAMBDA · 다른 통합문서 없음) | +| 틀 키 `EQ:{기계}.가격` · `LB:{직종}` | 기계 · 인력 표 사본 + INDEX/MATCH | 막힘에 가까움 | 마스터 654 기계 줄을 시트마다 복사 | +| 덧줄 · 비목 합 × 비율 | `SUMIF(비목 열, "노무비", 금액 열) * 율` | 됨 | — | +| 덧줄 `줄.'이름'` | 칸 참조 | 됨 | — | +| 끝수 묶음 | 계 칸 ROUNDDOWN | 됨 | — | +| 돈 아닌 결과 식(작업량) | 결과 칸 하나 | 됨 | 다른 로직이 그 결과를 부르는 길은 위 「로직 부름」 과 같이 막힘 | + +### 4-2. 시트 → 로직 (거꾸로) + +- 자유 수식(A1 참조 · SUMIFS · INDEX · TEXT …)은 로직 식 문법(`_틀.md` 8장 — `+ - * /` · 비교 · 그리고/또는 · 찾기 · 로직 · 만약 · 올림/버림/반올림 · 최소/최대)에 짝이 없음 → **아무 시트나 로직으로 바꿀 수는 없음**. +- 되는 꼴 = 「제한된 시트」 — 칸 자리(입력 · 산출근거 · 항목)를 틀로 고정하고, 수식은 로직 문법 안의 함수만 · 표 찾기는 자체 함수 `찾기(표, 조건=…)` 하나로 → 이러면 사실상 「시트 옷을 입은 로직 식」 이고, 사용자는 여전히 식을 씀. +- 원문 표가 시트 안에 사본으로 들어가면 표 3,033개(로직이 부르는 것 2,015 · 한 표를 여러 로직이 씀)가 로직마다 복사 → 품셈 숫자가 바뀌면 사본마다 고쳐야 함. 피하려면 원문 표 시트를 「마스터 표를 비추는 읽기 시트」 로 두는 새 기능 필요(다른 통합문서 참조에 해당). + +### 4-3. B 를 하려면 새로 필요한 것 + +1. 엔진에 자체 함수 — `찾기(표키, 조건…)`(범위규칙 · 무관 · 해당 없음/중복 멈춤까지 파이썬과 같게) · `단가(키 또는 검색어)` · `로직(키, 인자…)` — 엔진 · 계약 주인의 고침 + 마스터 자료를 엔진에 넣는 길(브라우저 · Node 둘 다). +2. 시트 → 로직 변환기(저장 때) + 로직 → 시트 생성기(열 때) + 왕복 시험 1,469 개. +3. 또는 시트를 정본으로 두면 파이썬 엔진과 TS 시트 엔진의 거울 시험(분수 대 Decimal). +4. 알약 → 입력값 → 금액 사슬 잇기(지금 끊김 — 1-1). + +--- + +## 5. A 대 B 판단 거리 + +| 목적 | A 세 칸 판(원문 표 · 산출근거 · 결과 · 고르기) | B 엑셀 시트형 | +| --- | --- | --- | +| ① 원문 대조 확인 | **좋음** — 왼쪽에 마스터 표 그대로(사본 아님) · 걸린 줄 칠함 · 출처 칩 · 산출근거는 문장. 걱정: 원문 「주」 글은 표 밖이라 연결 표시를 따로 만들어야 함 | 좋음 · 걱정 — 표는 엑셀처럼 보이나 규칙은 칸 수식 속에 숨음(A1 참조) · 계산 단계 보기 · 화살표로 따라갈 수는 있음(`ss/spreadsheet_eval_steps.ts` · `ss/spreadsheet_trace.ts`) | +| ② 품셈 숫자 바뀜 직접 고침 | **좋음** — 마스터 표 칸 하나 고치면 그 표를 쓰는 모든 로직에 반영(표는 한 벌 · `용도.로직키` 로 영향 보기) | **걱정 큼** — 표가 로직마다 시트 사본이면 사본마다 고침 · 피하려면 「마스터 표를 비추는 시트」 새 기능 | +| ③ 혼자 새로 만들기(초등학생) | 좋음 — 단계 안내 · 선택 · 문장 틀(할증 · 나누기 · 조건 목록) · 식 안 씀. 걱정: 틀 밖 규칙(드문 식 10 %)은 「직접 입력 식」 으로 남음 | **걱정 큼** — 결국 수식 쓰기(SUMIFS · 무관 · 범위규칙) · 9-21 「식으로 새로 만들기」 와 같은 벽 · 엑셀 아는 어른에게만 쉬움 | +| ④ 많은 수 검증 | 좋음 — 표 조회 「해당 없음 / 중복」 검사 · 기대값 시험표 · 진행표 · 서버 일괄(`check_master 계산전부`)을 그대로 씀 | 반쯤 — 시트 하나씩은 단계 보기가 좋으나 1,469 개를 시트로 바꾸는 일 자체가 큼 · SUMIFS 가 0 을 조용히 내면 틀림을 놓침 | +| ⑤ 서버 엔진과 같은 값 | **좋음** — 저장은 지금 로직 형식 · 계산은 파이썬 정본 한 벌(③) · 판은 편집기일 뿐 | **걱정 큼** — 시트 값(TS · 분수)과 정본(파이썬 · Decimal) 두 엔진 · 단가 붙이기(노임 · 자재 · 기계경비)는 시트가 못 해 서버 칸 채우기 필요 · 거울 시험 새 짐 | + +### 5-1. 정리 + +- **본 갈래 = A.** 정본 형식 · 엔진 · 마스터 표를 그대로 두고 화면만 바꿈 — 다섯 목적 모두 「좋음」 쪽. 오늘 만든 쉬운 단가산출(Api · 카드 · 일위대가표)이 오른쪽 「결과」 칸으로 그대로 들어감. +- **B 에서 빌려 올 것(작게)** — ① 왼쪽 원문 표를 스프레드시트 격자(읽기 · 칸 눌러 고침)로 보이기 ② 계산 단계 보기처럼 「산출근거 한 줄씩 값으로 풀기」 ③ 입력값 선택 목록(데이터 유효성 모양) ④ 선행/종속 화살표 → 「이 값이 어디서 왔나」. +- **B 시험판을 꼭 만든다면** — 범위를 「제한된 시트」 로: 원문 표 시트(마스터 표 비춤 · 읽기) + 입력 칸 + 산출근거 칸(로직 문법 함수만 + `찾기()` 자체 함수) + 항목 칸(단가는 서버가 채움) · 저장은 로직 형식으로 바꿔 파이썬이 셈. 필요한 엔진 고침(자체 함수 · 마스터 자료 주입)은 「0 계약 주인」 몫이라 브레인 결정 필요. +- **따로 알릴 것** — M02 면적 · 재료 시트의 「알약 → 입력값 → 금액」 사슬이 끊겨 있음(1-1) · B 와 상관없이 B08 · B09 다시 지을 때 이을 자리. diff --git a/docs/raw/M01_쉬운단가/로직판_조사_A_견적프로그램.md b/docs/raw/M01_쉬운단가/로직판_조사_A_견적프로그램.md new file mode 100644 index 000000000..f48d12a6f --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_조사_A_견적프로그램.md @@ -0,0 +1,100 @@ +# 35-0 조사 A — 견적 · 단가산출 프로그램은 로직을 어떻게 만들고 고치게 하나 (sub6 · 조사만) + +- 웹 검색 · 읽기만. 로그인 · 결제 필요한 곳은 겉 설명만. 「(추정)」 = 소개문만 보고 짐작 · 화면 · 설명서로 확인 못 함. +- 주소는 실제로 열었거나 검색 결과에 나온 것만. 「(검색만)」 = 열기 실패 · 요약만 봄. 사진 주소는 쪽 안 상대 경로를 펼친 것이라 직접 열어 보지 않음. + +## 0. 한 줄 결론 + +1. 국내 프로그램은 거의 모두 「**기초단가(단가대비표) → 일위대가(호표 행) → 내역서 → 원가계산서**」 한 줄 흐름 · 일위대가 행 = 품명 · 규격 · 단위 · 수량 × 단가 → 재료비 · 노무비 · 경비. 품셈 식을 사용자가 짜는 화면은 없음 — **표준일위대가 DB를 받아 수량 · 단가만 고치는 꼴**이 기본. +2. 품셈 개정 = 회사가 **DB 갱신으로 내려 줌**(조달청 표준일위대가 연 1회 · 노임 상 · 하반기 · 자재 달마다 · 2025년부터 품셈 상반기 1월 · 하반기 8월). 「품셈 연도 골라 쓰기」를 밝힌 곳 없음. +3. 해외는 「**입력값 → 계산 → 결과**」를 갈라 보이고(InEight), 입력을 **물음 · 고르기**로 받아 줄이 바로 생기게 하고(DESTINI · PlanSwift), **조회 표를 한 줄 종류로**(Sage 1 · 2차원 표) 둠. 라이브러리와 사본의 관계(복사 / 연결 / 새 견적만 반영)를 **한 규칙으로 정해 화면에 밝힘**. +4. 산림(임도 · 사방) 전용 일위대가 도구는 못 찾음 — 산림청은 「산림사업 표준품셈」 PDF만 배포 · 실무는 범용 프로그램이나 엑셀로 보임(추정). 우리 판의 빈자리. + +## 1. 국내 + +| 프로그램 | 사용자가 하는 일 | 화면 모양 | 좋음 | 걱정 | 개정 반영 | 우리 판에 가져올 것 | 주소 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| EST plus (다산소프트) | 기초대가(노임 · 자재 · 중기) → 일위대가 · 단가산출 → 내역서 · 원가계산서 | 풀다운 메뉴 + 그리드 + 트리 · 설계서 9개 동시 비교 | 기초대가 하나로 문서 일괄 | 화면 세부 미확인 | 인터넷 무상 갱신 · 조달청 표준일위대가를 연 1회 EST 형식으로 배포 | 「기초단가 한 곳 → 모든 일위대가 따라 바뀜」 | dasansoft.com/products/?mname=products01&sub=2 · 사진: dasansoft.com/products/images/est01.gif ~ est005.gif | +| EMS7 (고려전산) | 표준품셈 DB에서 일위대가 · 단가산출서 입력 → 설계 · 견적 · 실행 · 기성 · 설계변경까지 | 트리 / 그리드 · 두 프로젝트 비교(일부 추정) | 한 번 입력으로 전 과정 · 조달청 호환(C3R) | 유료 · 배우기 어렵다는 말 | 「수시 업데이트 품셈 개정 · 단가 DB」 온라인 갱신 | 당초 / 변경 비교 보기 | koreasoft.co.kr/m/pub/product/ems.asp · 사진: koreasoft.co.kr/img/ems01.jpg · ems02.jpg | +| Easy 2.0 (건설연구원 — 품셈 책 출판사) | 등록된 표준일위대가 · 재료비 · 노무비 · 경비를 **찾아 가져다 씀** · 엑셀로 바꿔 수정 | 찾기 중심 목록(추정) | 품셈 원문 곁에 만든 곳 · 조달청 호환 인증 | 분야별 따로 판매 | 재료비 달마다 · 품셈 정오표 공지 | 입구 = 「찾아서 가져오기」 | 04info.co.kr | +| XCOST / XCOST Pro (하우코스트) | 프로젝트 → 단가대비표를 자재 화면에 붙여넣기 → 일위대가 목록을 내역 화면에 붙여넣기 → 같은 코드면 일위대가 자동 생성 · 없으면 새 자재코드 → 「품목구분」을 일위대가로 바꿔야 고칠 수 있음 | 듀얼 화면(수량산출서 ↔ 내역서) · 엑셀식 붙여넣기 | 무료판 · 엑셀 호환 | 붙여넣은 일위대가가 처음엔 「자재」로 잠김(숨은 규칙) | 달마다 통합 일위대가 배포(예 2025-11판 = 하반기 노임 + 11월 자재 + 2025.8 품셈 + 10월 조달청 단가) · 조달청 표준공사코드 병기 | 판마다 「무엇이 들어간 판인지」 한 줄 표시 | xcost.me/bbs/board.php?bo_table=tip&wr_id=128 · xcost.me/bbs/board.php?bo_table=data&wr_id=241 | +| SGS2025 (나스) | 기초자원(자재 · 노임 · 견적) → 단가산출(산식 사전 · 선 그리기로 도표) → 일위대가 자동 → 예산내역서 | 위아래 2단 · **작업 화면 = 인쇄 결과물** · 오른쪽 클릭 메뉴 · 관급 / 사급 색 구분 | 화면과 출력물이 같아 실무자가 바로 읽음 | 단가 일괄 인상 · 인하 기능 오용 위험 | 노임 · 건설기계 가격 상 · 하반기 무상 · 「최신 단가 자동 변경 링크」 | 「실무 일위대가표로 보기」를 화면 모양 그대로 | nas.co.kr/nas/product/sgs02.php · nas.co.kr/nas/product/sub_page_sgs/sgs_intro_slide12.php · 사진: nas.co.kr/nas/img/sgs_flow/12_img01.jpg | +| EBS (글로텍) | 기초단가(재료 · 노무 · 경비 · 기계경비) → 일위대가(산출근거 · 호표 · 별표) → 내역서 | 미확인 | 연도별 제경비율 자동 | 공개 정보 적음 | 물가지 DB · 노임 적용 | — | glotechsoft.com/ebs · 사진: glotechsoft.com/resources/img/works/ebs.png | +| EG 견적 (이지테크) | 일위대가 품명 · 규격 입력 → [≡2] 단추로 세부 → Tab 으로 자재 불러오기 / 직접 → 옵션계산 → Ctrl+I 「내현장 일위대가」 → 결정수량 | 총괄표 / 세부 계층 그리드 | 분야(전기) 도움말 자세함 | 단축키 · 기호 단추 의존 | 미확인 | 「내 현장 일위대가」 = 우리 「내가 만든 것」과 같은 갈래 | 2156.co.kr/egHelp/eg9_4.html (검색만) | +| JOINT (아성정보) | 자재 수량 입력 → 재료비 · 노무비 · 경비 자동 · 일위대가 목록 · 단가대비표 일괄 출력 | 미확인 | 조달청 호환 | 홍보성 정보만 | 「표준품셈 입력 제공」 · 정부노임 | — | m.cafe.daum.net/lineone/4QR6/5496 | +| KICA 공사비산정 (정보통신공사협회 · 웹 무료) | 품셈 공종 고름 → 규격 · 단위 · 수량 → **할증 칸** → 기계경비(장비 · 규격 · 사용시간) · 기타경비(10개까지) → [산정하기] → 엑셀 저장 | 웹 폼 · 칸 묶음마다 저장 · 복사 · 수정 · 삭제 | 무료 · 8,187 공종 · 연관 검색어 | 정보통신 전용 | 해마다 「20XX년도 적용 품셈」 · 노임 · 조달청 간접비 기준으로 통째 교체 | **순서 있는 물음 폼**(무엇 → 얼마 → 할증 → 기계) · 「적용 품셈 연도」를 화면 머리에 | ictis.kica.or.kr/tech/testConstructCost/inquiry · koit.co.kr/news/articleView.html?idxno=102431 | +| 조달청 공사원가통합관리(NPCCS) · 통합원가계산 | 내역서 → 자원(자재 · 노무 · 경비) → 일괄 재계산 → 원가계산서 탭 | 웹 탭(추정) | 표준공사코드 · 표준시장단가 · 자원코드 = 공식 기준 | 공동인증서 필요 · 열람 못 함 | 표준일위대가를 해마다 개정 품셈으로 다시 공개 · 자원코드 · 시장단가는 공공데이터포털 반기 | 공식 코드를 곁에 적어 둘 칸 | npccs.g2b.go.kr (검색만) · data.go.kr/data/15151298/fileData.do (검색만) | +| 한국물가정보(KPI) 적산정보 | 표준 · 실용 일위대가 **조회 · 출력** · 표준 일위대가로 간이 내역서 | 공종 분류 트리 → 일위대가 표 | 물가와 연동 | 「미적용 품목」 있음 · 현장마다 다를 수 있음을 스스로 밝힘 | 반기(1월 · 9월) | 「이 값은 표준 · 현장 따라 다름」 한 줄 | kpi.or.kr/www/estimate/ · kpi.or.kr/www/2020/kpi_juseinfo.asp | +| 산림 분야 | 전용 일위대가 도구 못 찾음 · 산림청 「산림사업 표준품셈」 PDF(조림 · 숲가꾸기 · 사방 · 임도 등 7개 사업 통합 · 2024 개정판 2025.1.1 시행) · 공공데이터 「산림토목 사업종별 기준 단가」 | — | 무료 · 통합 | 만드는 도구 없음 | 품셈 PDF 개정판 | 우리 판이 채울 빈자리 | ftims.forest.go.kr/cop/bbs/selectArticleDetail.do?nttId=2696&bbsId=BBSMSTR_000000000001 · data.go.kr/data/15041979/fileData.do (검색만) | + +- 이름만 나오고 근거를 못 찾은 것: STmate · 코스트맨 · 견적왕 · A-Cost · 빌드코스트 · CostPlanner. 한국물가협회는 일위대가 DB(1만 2천여 건)만 확인. +- 품셈 개정 흐름: 국토부 공고 · 한국건설기술연구원 공사비원가관리센터 관리 · 2025년부터 「수요응답형」 상 · 하반기 반영(koscaj.com/news/articleView.html?idxno=112754 · sankun.com 블로그 · codil.or.kr (검색만)). + +### 1-1. 표준 서식 열 (조달청 「설계내역서 작성 매뉴얼」 PDF 본문 확인) + +- 일위대가표 머리 = 번호(호표) + 품명 + 단위 — 예 「1-1 가설전력인입 (일위대가) 식」 · 아래 행 = 자재 행 + 「노무비 배전전공 인」 행. +- 일위대가표 열 = 일위대가표번호 · 자재코드 · 품명 · 규격 · 단위 · 수량 · 재료비 · 노무비 · 경비 · 합계(각 단가 · 금액). +- 일위대가 목록 = 코드 · 품명 · 규격 · 단위 · 재료비 · 노무비 · 경비. +- 내역서 = 품명 · 규격 · 단위 · 수량 · 재료비 · 노무비 · 경비 · 합계(단가 · 금액) · 비고. +- 단가대비표 = 품명 · 규격별 가격 출처(물가자료 · 거래가격 · 유통물가 · 조사가격)를 나란히 놓고 적용단가 고름(열 이름은 추정). +- 실무 흐름 = 단가대비표로 자재 · 노임 · 중기 단가 확정 → 품셈 항목 골라 호표 행 입력(재료 수량은 할증 포함 · 노무 인수 · 공구손료 · 잡재료 % 행) → 행마다 수량 × 단가 → 재료비 · 노무비 · 경비 합 → 일위대가 목록 → 내역서 → 원가계산서. +- 「별도」 표기 = 시멘트 · 모래처럼 일위대가에서 빼고 따로 잡는 재료. +- 주소: kira.or.kr/gongji_2008/조달요청 건축공사 설계내역서 작성 매뉴얼(최종).pdf · ktword.co.kr/test/view/view.php?no=2264 (검색만) + +## 2. 해외 + +| 제품 | 사용자가 하는 일 | 화면 모양 | 표(생산량 · 계수) 연결 | 개정판 관리 | 좋음 | 걱정 | 우리 판에 가져올 것 | 주소 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| RSMeans Data Online | 단가 줄 · assembly 고름 → 「Customize Assembly」 = 구성 줄 Add / **Swap** / Remove · 수량 · 「Insert Custom Line」 · 고친 줄에 **연필 표시** | 견적 그리드 → assembly 펼치면 구성 목록 | 줄마다 **Crew**(예 C-1 = 목수 4 + 인부 1) · **Daily Output**(8시간당 생산량) · 노무시간/단위 = 작업조 시간 ÷ 생산량 · Material / Labor / Equipment 갈라 봄 | 견적 만들 때 **Release(연도 · 분기) · Location · Labor Type** 고름 | 작업조 → 생산량 → 단가 사슬이 눈에 보임 | 직접 넣은 줄은 자동 계산 없음 | 「바꾸기(Swap)」 단추 · 고친 줄 연필 표시 · 판(연도) 고르기 | rsmeans.com/resources/creating-unit-assembly-estimates-rsmeans-online · gordian.com/resources/cost-item-assemblies-improving-accuracy/ | +| Sage Estimating | 물량 뽑기 → 「Save As Assembly」(**Database** 재사용 / **One-time** 이 견적만) → Insert 로 줄 추가 · 오른쪽 클릭 Substitute · 줄마다 계산 방법 | assembly 줄 그리드 + 물량 뽑기 때 **변수 칸** | 줄 계산 기호 I(식) · *(계수) · F(고른 식) · fy(사용자 식) · **Item table 1 · 2차원**(예 경간 × 규격 → 장선) · 「Select-at-takeoff」 = 그때 고르라는 물음 | 「Price Update」 미리보기 · **기본 DB 단가 고쳐도 이미 만든 견적은 안 바뀜**(새 견적부터) · +x% 뒤 −x% 해도 원값 아님 경고 | 2차원 조회 표가 강함 | 한 글자 기호가 암호 | 「표 보고 고르기」를 한 줄 종류로 · 고친 값이 어디까지 퍼지는지 한 줄 | help-sageestimating.na.sage.com/en-us/23_1/Content/assembly/item_grid_assembly_window.htm · …/item_tables_concept.htm · …/22_1/Content/pricing/updating_database_prices_by_category.htm · …/21_1/Content/takeoff/saving_items_as_an_assembly.htm · 사진(추정): …/23_1/Content/Resources/Images/it-2dim.gif | +| InEight Estimate | 다섯 걸음 — ① 결과 **Cost Items** ② **Inputs**(값 · 표 · 글 입력 · 최소 / 최대 검사 · 기본값 · 조건부 보임) ③ **Tables** ④ **Calculations** ⑤ 칸 오른쪽 클릭 「Link this field to Calculation Result」 | assembly 한 장에 블록 — Cost Items 트리 · Inputs · Calculations · Tables · **Notes(근거 · 그림)** · 식 편집기(칸 · 상수 · 연산 · 함수 고르기) | `[Length]*[Width]*[Thickness]/27` · 표는 assembly 전용 · 프로젝트 · **Master** 세 층 · 결과를 생산량 · 자원 수량에 이음 · **틀린 식은 「?」 · 적용 막음** | 라이브러리 = 마스터 · 새 폴더는 마스터를 **복사** · 지역 · 임금 구역 거름 · 「마지막 고친 사람 / 때」 | 입력 → 계산 → 결과 갈라 보기 + 근거 메모 | 초보에게 무거움(변수 이름 · 괄호 문법) | 깨진 결과 「?」 · 로직마다 「근거」 칸 · 입력 기본값 · 범위 검사 | learn.ineight.com/Estimate-On-Premise/Content/15-CI-Assemblies/15%202%20Cost%20Item%20Assembly%20Creation.htm · learn.ineight.com/Estimate/Content/3-LibrarySetup/3%201%20Library%20Overview.htm | +| DESTINI Estimator (Beck) | 물량(면적 · 둘레 · 높이) 넣음 → **물음 4개쯤**(고르기)에 답하면 **오른쪽에 줄이 바로 생김** → 단가 → % 물음(손실)로 수량 더함 | 왼쪽 물음 판 · 오른쪽 줄 목록(실시간) | assembly = 줄 + 고르기 + 식 · 식 이름에 단위를 넣음(`fx_FootingVolumeCUYD`) | Cloud Cost Library(세부 못 찾음) | 「가장 경험 적은 견적자를 위해 설계」 | 로직 짜는 일은 여전히 전문가 몫 | 물음에 답하면 줄이 생기는 마법사 · 이름에 단위 | beck-technology.com/blog/destini-estimator-assemblies-tutorial · …/how-to-build-assemblies-tips-and-tricks · 사진: beck-technology.com/hs-fs/hubfs/Blog%20Images/Assembly%20Map.jpg | +| RIB CostX | 단가 라이브러리에 Rate(코드 · 지역 · 묶음 · 단위) → 복합 단가는 **build-up 시트** · 단가를 워크북에 끌면 식이 자동 | 단가 목록 + 엑셀형 워크북 + build-up 시트 | `XGETRATE` · `XSUMRATE` · `XGETNAMEDCELL`(할증 · %) · 코드 머리 L / P / M / S(노무 · 장비 · 재료 · 외주) | 복합 단가가 원 단가를 **참조** → 노임 오르면 모두 따라 바뀜 · 워크북을 다른 라이브러리로 바꿔 재계산 · 관리자 잠금 · 지역별 라이브러리 | 연결 단가 + 지역 · 연도 라이브러리 통째 바꾸기 | 식 함수가 엑셀 말 | 「단가 판(연도 · 지역) 바꾸기 → 한 번에 재계산」 | rib-software.com/en/blogs/rib-costx-rate-library · …/rib-costx-database-rate-libraries · …/rib-costx-functions | +| CCS Candy | 분석 워크시트에 자원 코드를 치거나 목록에서 끌어 넣음 · 오른쪽 클릭 「Edit/New resource」 | 글 계산 줄 워크시트 + 자원 목록 | 단순 / 복합 자원(9층까지 · 예 「콘크리트 작업조」) · `#` = **아래 줄에 함께 쓰는 생산량**(예 400㎥/일) · `+5%waste` 손실 | 자원 단가 고치면 모든 워크시트 · Nett 단가 갱신 | 손계산처럼 읽힘 · 근거 추적 강함 | 쳐 넣는 문법이라 실수 쉬움 | 「공통 계수 한 줄」(줄마다 반복 대신) · 손실 % 칸 | slideshare.net/slideshow/candy-construction-estimating-valuations-rev-201/10113582 · constructioncomputersoftware.com/products/candy/candy-features/estimating/ (검색만) | +| Causeway Estimating | 라이브러리 자원을 **rate build-up** 에 끌어 넣음 · 노무 · 장비 · 재료를 재사용 「activity resource」로 묶음 | 라이브러리 트리 + build-up 그리드(추정) | 자원에 손실 · 탄소 값 | 자원 단가 고치면 「다음에 쓸 때」 반영(기존은 그대로인 듯 · 추정) | 끌어 넣기 | 연결인지 사본인지 흐림 | — | causeway.com/blog/estimating-efficiency-with-causewayones-library-maintenance-feature-0 | +| Cubit (Buildsoft) | **Rate Sheet** 에서 복합 단가(예 문 한 짝) 짬 · **Simple / Material·Labour 두 모드** | Rate Sheet 그리드 | 라이브러리 가격표 틀 | 못 찾음 | 쉬운 모드 전환 | — | 「쉬운 보기 / 실무 보기」 전환 | bsoft.zendesk.com/hc/en-us/articles/232310448 (검색만) | +| HCSS HeavyBid | Master Estimate · **Activity Codebook** 에서 활동을 가져옴 · 작업조 바꾸기(Edit > Replace Crews) | 공종 → 활동 → 자원 세부 판 | 코드북 활동에 기본 작업조 · 자원 · 생산량 · 물량 계산 · 현장(HeavyJob) 생산 이력 | 회사 코드북을 견적에 **복사** | 현장 실적에서 생산량 | 작업조가 너무 많아짐(업체 글: 12개 → 3개로 줄이라) | 처음엔 몇 개 기본 묶음만 | hcss.com/blog/spring2018-codebooks/ · heavybid.ewksol.com/index.php/2017/05/30/simplify-your-crews-and-activity-codebook/ | +| B2W Estimate | Crew Template 에 구성(노무 · 장비) · 하루 시간 · 기본 생산량 · 재료는 Task / Item 틀에 | 목록 탭 + 세부 탭 | 생산량 단위 UM/DY · DY/UM · UM/PH · UM/MH 등 | 틀을 넣으면 「**carbon copy**」 · 뒤에 원본 고쳐도 안 바뀜 · 생산량 바꾼 **이력(옛 값 → 새 값)** | 복사 규칙이 분명 · 바뀜 기록 | 사본이 원본과 멀어짐 | 옛 값 → 새 값 기록 | help.trimble.com/doc/b2w-estimate/b2w-estimate/about-resources/costing-resources/crew-templates-resource | +| PlanSwift | 부품 · assembly 를 물량 위로 끌면 **변수 창 하나**가 떠 모든 입력을 한 번에 → OK | 틀 판 + 변수 창 | 고급 속성 식 `[!sum(Price Total)]` | 기본 DB | 입력을 한 창에서 다 물음 | 식 문법이 숨겨진 곳에 | 「설계 때 정하는 값」을 한 창에서 | planswift.com/blog/use-parts-assemblies/ | +| ProEst (Autodesk) | DB 에서 단가 줄 · assembly 추가 · assembly 는 「식 변수에서 생성」(교육 목차만) | 미확인(도움말 403) | 지역 단가 · 계수 2만 3천 줄 | 자체 DB 가져오기 / 기본 DB | 큰 시작 DB | 식 작동 공개 설명 적음 | — | autodesk.com/products/proest/overview · learnacc.autodesk.com/proest-estimator (검색만) | +| RIB iTWO 4.0 | 공개 설명 못 찾음(assembly 마스터 → 자원 · 수량 · 원가 계수 · 수량 식 — 추정) | — | — | — | — | 공개 문서 없음 | — | — | + +### 2-1. 해외에서 우리 판에 가져올 것 (갈래 순 · 설계 후보) + +1. 로직 한 장을 **설계 때 정하는 값(입력) → 줄(계산) → 합계(결과)** 세 칸 + **근거 메모** 칸으로(InEight Notes). 브레인 제안서 세 칸(원문 표 · 만드는 줄 · 결과)과 같은 갈래 — 「원문 표」 칸이 근거 메모 몫. +2. 입력은 기본값 · 범위 검사 · 한 창에서 다 물음(InEight · PlanSwift) — 우리 「설계 때 정하는 값」 띠. +3. 물음에 답하면 줄이 바로 생김(DESTINI) — 우리 「물음 셋(무엇 → 어떤 것 → 얼마)」과 같은 갈래 · 근거 있음. +4. **조회 표를 한 줄 종류로**(Sage 1 · 2차원 표 · InEight Tables) — 우리 2단계 「표 보고 고르기」 근거. +5. **공통 계수 한 줄**(Candy `#`) · 손실 % 칸 — 우리 「덧붙임(%)」 카드. +6. 깨진 결과는 「?」 + 저장 막음(InEight) — 우리 「잘못 넣음은 그 카드 안 한 줄」. +7. 라이브러리 ↔ 사본 규칙을 하나로 정해 화면에 밝힘 — B2W(사본 · 원본 고쳐도 안 바뀜) · CostX · Candy(연결 · 따라 바뀜) · Sage(새 견적부터). 우리 「품셈 기본 → 이걸 바탕으로 고치기」(사본)는 B2W 갈래 — 「품셈이 개정돼도 내가 만든 것은 안 바뀜」 한 줄 필요. +8. 고친 줄 표시(RSMeans 연필) · 옛 값 → 새 값 기록(B2W) · 「바꾸기(Swap)」 단추(RSMeans) — 단가 자료 바꾸기 걸음을 줄 하나로. +9. 판(연도 · 지역) 고르기 · 통째 바꿔 재계산(RSMeans Release · CostX) — 국내 KICA 「20XX년도 적용 품셈」 머리와 같은 갈래. +10. 쉬운 보기 / 실무 보기 전환(Cubit Simple · Material/Labour 모드 · 국내 SGS 「화면 = 출력물」) — 우리 「실무 일위대가표로 보기」(읽기) 근거. + +## 3. 용어 표 — 우리 화면 낱말 고를 근거 + +| 뜻 | 국내에서 쓰는 말 | 해외에서 쓰는 말 | 우리 설계 낱말(설계.md 8) | 메모 | +| --- | --- | --- | --- | --- | +| 단가산출 로직 한 벌 | 일위대가 · 일위대가표 · 단가산출서 · 단가산출조서 · 내현장 일위대가(EG) · 실용 일위대가(KPI) | Assembly · Cost Item Assembly · Rate build-up · Composite rate · Recipe · Activity | 일위대가 · 내가 만든 것 · 품셈 기본 | 국내 실무는 「일위대가」가 압도적 | +| 한 벌의 번호 | 호표 · 별표 · 일위대가표번호 · 코드 | Item code · Rate code | (숨김) | 서식 머리 「1-1 …」 꼴 | +| 줄(자원) | 재료 · 노무 · 경비 · 기계경비 · 중기 · 자재 · 노임 | Resource(Labor · Equipment/Plant · Material · Subcontract) · Component · Part | 사람 · 재료 · 기계 · 다른 일위대가 | 국내 서식 열은 재료비 · 노무비 · 경비 | +| 넣을 자리(비목) | 재료비 · 노무비 · 경비 · 직접 / 간접노무비 · 산출경비 | Material / Labor / Equipment cost · Bare cost | 재료값(재료비) · 사람 품값(노무비) · 기계·경비(경비) | 괄호 속 공식어를 같이 두는 것이 실무 이해에 맞음 | +| 입력값 | 설계수량 · 결정수량 · 수량 | Variable · Input · Takeoff dimension · Question / Choice · Select-at-takeoff | 설계 때 정하는 값 | 해외는 「물음」 꼴로도 받음 | +| 고정값 · 기본 단가 | 기초단가 · 기초대가 · 기초자원 · 적용단가 · 단가대비표 | Constant · Default value · Bare cost · Simple resource · Rate | (단가는 마스터에서 자동) | 단가대비표 = 가격 출처 나란히 · 우리 시험 계산의 물가 넷과 같음 | +| 결과값 | 단가 · 합계 · 금액 · 일위대가 단가 | Calculation result · Nett rate · Composite rate · Total | 큰 합계 「1㎡ 만드는 데 … 원」 | — | +| 품 · 작업조 | 품 · 인(예 0.17인/㎡) · 인부 · 공 | Crew · Gang · Labor-hours per unit · Man-hour | 사람 · 「[0.072] 인 일함」 | 「인」 단위 그대로가 실무 말 | +| 생산량 | 작업량 · 시간당 작업량 · 시공량 · 1일 작업량 | Daily Output · Production rate (UM/DY · UM/PH) · Productivity · Applied factor | (결과 식 로직 · 쉬운 길로 못 엶) | — | +| 조회 | 표 · 품셈 표 · 적용 기준 · 조건(횡단경사 등) | Item table(1 · 2D) · Lookup · Table · Codebook · XGETRATE | 「표 보고 고르기」(2단계) | — | +| 할증 · 덧붙임 | 할증 · 재료할증(벽돌 5% 등) · 공구손료(노무비 3%) · 잡재료(2%) · 제경비율 | Factor · Applied factor · Waste % · Markup · O&P · Location factor | 덧붙임(%) | 국내 서식은 공구손료 · 잡재료를 % 행으로 둠 = 덧붙임 카드와 같은 꼴 | +| 산출근거 | 산출근거 · 단가산출서 · 수량산출서 · 「별도」 표기 · 정오표 | Notes · Basis · Rate build-up · Analytical worksheet · Event log · Last changed by/on | 원문 표(제안서 첫 칸) · 실무 일위대가표로 보기 | 근거 = 품셈 원문 번호 + 표 | +| 판(개정) | 20XX년도 적용 품셈 · 상 · 하반기 노임 · 표준일위대가 개정 · 품셈 정오표 | Release · Update · Price update · Library version · Location | (없음 — 후보) | 화면 머리에 「적용 품셈 · 단가 때」 한 줄 후보 | +| 사본 / 원본 | 표준일위대가 ↔ 내현장 일위대가 · 「품목구분」 바꿔야 고침(XCOST) | Database vs One-time(Sage) · Carbon copy(B2W) · Linked rate(CostX) · Master / Standard / Local table(InEight) | 품셈 기본 · 내가 만든 것 · 「이걸 바탕으로 고치기」 | XCOST 의 「잠긴 자재 → 품목구분 바꿈」은 우리 지금 화면의 「본떠 만들기」와 같은 숨은 규칙 — 피할 것 | + +## 4. 걱정 — 남들이 겪은 것 + +- 국내 프로그램은 DB 를 받아 고치는 꼴이라 품셈 식 자체를 고치는 화면이 드묾 — 「코딩 없이 품셈 원문 보며 로직 만들기」는 남이 안 한 영역 · 따라 할 견본이 적음. +- 숨은 기호 · 단축키(Sage 한 글자 계산 기호 · EG [≡2] · Ctrl+I · Candy `#` 문법)는 다 「배우기 어렵다」로 이어짐. +- 사본 방식(B2W · HeavyBid)은 원본 개정과 멀어짐 · 연결 방식(CostX)은 한 곳 고치면 어디까지 퍼지는지 모름 — 어느 쪽이든 규칙을 화면에 적어야 함. +- 단가 일괄 인상 · 인하(SGS) · +x% −x% 함정(Sage) — 숫자를 한꺼번에 바꾸는 단추는 위험. +- 작업조 · 틀이 늘어나면 고르기가 어려워짐(HeavyBid 12 → 3 권고) — 처음 고르기 목록은 짧게. diff --git a/docs/raw/M01_쉬운단가/로직판_조사_B_노코드도구.md b/docs/raw/M01_쉬운단가/로직판_조사_B_노코드도구.md new file mode 100644 index 000000000..4d658fab8 --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_조사_B_노코드도구.md @@ -0,0 +1,95 @@ +# 로직판 조사 B — 코딩 모르는 사람이 계산 규칙을 만드는 도구 (35-0 · sub8 · 10-01) + +조사만 · 코드 · git · docs 안 고침. 세 갈래 24개 도구를 공식 문서 · 교육 자료 · 비평으로 봄(인터넷 조사 창 셋 · 주소는 조사 창이 열어 본 것만). 비교 기준 = 브레인 제안서 「세 칸 판(원문 표 · 만드는 줄 · 결과) + 물음 여섯」. + +## 0. 한눈에 — 우리 판에 가져올 것 열 + +| # | 가져올 것 | 본보기 | 우리 판 자리 | +|---|---|---|---| +| 1 | **표 찾기 = 카드 한 장**(표 · 맞출 칸 · 꺼낼 칸 · 못 찾을 때) — 선 잇기 없음 | Zapier Lookup Table(Fallback 값) · Unreal Get Data Table Row(Row Found / Row Not Found) · CPQ 조회 질의(시험할 칸 ↔ 표 칸 → 가져올 칸) | 가운데 ② 「표에서 꺼냄」 | +| 2 | **맞는 줄 0개 · 2개 이상을 늘 검사해 보임** | CPQ(2줄 이상이면 계산 실패) · Airtable(목록 결과) · Workday(맞는 구간 없으면 빈 값) | ② 카드 아래 한 줄 · 저장 전 검사 | +| 3 | **조건은 줄 목록 — 위에서부터 처음 맞는 줄 · 맨 끝 「그 밖」** · 식 대신 고르기 | Glide 만약→그러면→아니면 칸 · Workday 표현식 평가(기본값) · Make Router(fallback 갈래) | ③ 「셈」의 「만약 ~면」 문장 틀 | +| 4 | **범위는 말로 고름(이상 · 미만 · 초과 · 이하) · 빈칸 = 「상관없음」** | DMN 빈칸 `-` · Trisotech 괄호(열림/닫힘이 초보 함정) · Workday 범위 구간 | 왼쪽 표 머리 · ③ 문장 칩 | +| 5 | **겹침 규칙은 고르게 하지 않음** — 「한 줄만 맞아야 함」 하나 · 할증처럼 여러 줄 더하기만 「모두 더함」 | DMN 적중 정책(U · F · C+) — First 는 따지기 어렵다고 Camunda 문서가 경고 | 엔진 규칙 그대로 · 화면엔 안 보임 | +| 6 | **빈틈 · 겹침 자동 검사 단추** — 설계 때 고르는 값의 조합 가운데 표에 줄 없는 것 · 둘 걸리는 것 | Trisotech Method & Style 분석 · Drools 정적 분석(빈틈 · 겹침 · 가려진 줄) | 원문 대조 진행표 · 저장 전 | +| 7 | **견본 값 고정 + 고칠 때마다 즉시 결과 + 한 단계씩 값** | Notion 실시간 미리 보기 · n8n 데이터 고정(pin) · Excel 「수식 계산」(한 단계씩) · Guidewire 요율 워크시트(어느 표 어느 줄 → 얼마) | 가운데 칸 ①~⑤ 칸마다 값 · 오른쪽 결과 | +| 8 | **기대값 시험표** — 넣은 값 → 원문 기대 → 실제 → 통과/실패 · 한 번 누르면 전부 다시 | Oracle Intelligent Advisor 시험 문서(PASS/FAIL) · Drools 시험 시나리오 | 「많은 수를 확인하는 길」 진행표 | +| 9 | **이름 붙인 결과를 다음 로직이 받음 · 정의 순서 · 물고 도는 것은 화면이 막음** | Excel 이름 정의 · Coda 이름 붙인 수식 · ConvertCalculator 변수(뒤에 정의된 것 부르면 오류) | ⑤ 「다음 로직이 부르는 이름」 | +| 10 | **저장마다 판 + 전후 비교 · 적용 시점 있는 값** | Camunda 버전 「Show changes」 · Make 저장마다 판(60일) · Guidewire 요율집 적용일 · 시프티 시급 적용 시점 | 품셈 개정판 · 되돌리기 | + +피할 것 — 선 잇는 흐름 그림(스파게티 · 9-21 지운 흐름 그림과 같은 벽) · 「고급 보기」로 식이 다시 나오는 길(Power Automate · n8n · Node-RED) · IF 겹치기 · 삼항(`? :`) · 적중 정책 고르기 · 이름 없는 변수(Unity A · B · ConvertCalculator QA · VA). + +## 1. 규칙 · 결정 표 + +| 도구 | 조회(표) 연결 | 조건 | 변수 · 입력 정의 | 초보가 막히는 곳 | 검증 | 되돌리기 · 이력 | 우리 판에 가져올 것 | 사진 · 설명 주소 | +|---|---|---|---|---|---|---|---|---| +| Camunda DMN | 표 하나 = 입력 칸 + 출력 칸 + 규칙 줄 · 왼쪽 위 적중 정책 | FEEL `<= 3` · `[3..7.5]` · 빈칸 `-` = 상관없음 | 칸마다 입력식 · 형식 | 적중 정책 고르기(First 는 문서가 경고) · FEEL 괄호 | Play 모드 — 값 넣고 바로 실행 | 버전 스냅숏 · 「Show changes」 비교 | 「모두 더함(C+)」 = 할증 합 | 사진 docs.camunda.io/assets/images/dish-table-8fddefbddf97d60eb60d5ff7fbacebe8.png · https://docs.camunda.io/docs/components/modeler/dmn/decision-table/ | +| Trisotech DMN | 이중선 왼쪽 입력 · 오른쪽 출력 | `(620..660)` · `[75..80]` · `< 620` | 형식에 허용 범위 | 열림 · 닫힘 괄호 · U 표인데 줄 겹침 | 분석 한 번에 빈틈 · 겹침 · 합칠 줄 · 시험 사례 | 못 찾음 | **빈틈 · 겹침 검사 단추** | 사진 methodandstyle.com/blog/dmn-101/loan-prequal-table-600x765.png · https://www.trisotech.com/dmn-101/ · https://www.trisotech.com/dmn-hit-policy-explained/ | +| Drools / Kogito DMN | DMN 표 · 머리 「Manage」 로 값 목록 | FEEL · 열거 · 범위 제약 | 형식에 열거 · 범위 | 다 만들기 전까지 빨간 오류가 계속 뜸 | 정적 분석(빈틈 · 겹침 · 가려진 줄) · 시험 시나리오 기대 대 실제 | 못 찾음 | 「가려진 줄」 경고 | https://docs.drools.org/8.38.0.Final/drools-docs/docs-website/drools/DMN/index.html | +| Oracle Intelligent Advisor | 엑셀 안 규칙표 · 칸 서식으로 조건 / 결론 구분 | `>3` · between · `else` | 속성마다 자료형 먼저 | 선언 안 한 속성은 못 씀 | 엑셀 시험 문서 — 기대 칸 · Run · PASS/FAIL | 못 찾음 | **기대값 + 통과/실패 시험표** | https://docs.oracle.com/en/cloud/saas/intelligent-advisor/using-policy-modeling/Content/Guides/Use_Intelligent_Advisor/Use_Policy_Modeling/Work_with_rules/Write_rules_in_Excel/Create_a_rule_table_in_Excel.htm | +| Guidewire Rating | 요율표(매개변수 칸 → 요율계수) · 요율 루틴이 조회 · 사칙을 단계로 이음 | 일치 · 범위 · 할증 최소 · 최대 % | 매개변수 묶음 | 루틴이 길어져 따라가기 어려움 | **요율 워크시트 — 계산 단계를 하나하나** | 요율집 초안 → 승인 → 적용 · 적용일 | **계산 과정 단계표** | https://docs.guidewire.com/cloud/apd/laslenas/create/topics/c_rate-modifiers.html | +| Salesforce CPQ 가격 규칙 | 조회 질의 = 시험할 칸 + 연산자 + 조회표 칸 → 넣을 칸 | 조건 줄(칸 · 연산자 · 값 고르기) · 모두 / 하나라도 / 직접 조합 | 사용자 정의 개체 = 조회표 | **2줄 이상 걸리면 실패** · 수식 문법 검사 없음 | 따로 없음 | 못 찾음 | 조합 중복 금지 · 「찾을 칸 ↔ 표 칸」 짝 줄 | 사진 res.cloudinary.com/hy4kyit2a/f_auto/fl_lossy/q_70/learn/modules/price-rules-in-salesforce-cpq/formulate-your-conditions-and-actions/images/a11a3fc5b6676b9a81c5e40c9553a668_kix.21xradbunzew.png · https://trailhead.salesforce.com/content/learn/modules/price-rules-in-salesforce-cpq/formulate-your-conditions-and-actions | +| Workday 계산 필드 | 범위 구간 조회(시작 ~ 끝 · 없으면 빈 값) | 표현식 평가 — 위에서 처음 참 · 기본값 | 결과 형식 먼저 · 칸마다 드롭다운 | 조건 순서가 결과를 바꿈 · 함수 종류 혼동 | 못 찾음 | 못 찾음 | **식 대신 고르기 칸 + 기본값 칸** | https://doc.workday.com/admin-guide/en-us/manage-workday/tenant-configuration/calculated-fields/calculated-field-functions/dan1370796323223.html | +| 시프티 실급여정산(한국어) | 근로정보(시급)로 수당 자동 | 고정 항목(연장 · 야간 · 휴일 · 주휴) | 시급마다 적용 시점 | 못 찾음 | **수식이 남는 엑셀로 내보내 사람이 검산** | 적용 시점별 이력 | 적용 시점 있는 값 · 검산 내보내기 | https://shiftee.io/ko/help/article/payroll | + +덕크릭(Duck Creek)은 공개 문서가 모자라 뺌. + +## 2. 노코드 수식 · 계산기 · 견적 규칙 + +| 도구 | 조회(표) 연결 | 조건 | 변수 · 입력 정의 | 초보가 막히는 곳 | 검증 | 되돌리기 · 이력 | 우리 판에 가져올 것 | 사진 · 설명 주소 | +|---|---|---|---|---|---|---|---|---| +| Excel(한국어판) | XLOOKUP(찾을 값 · 찾을 범위 · 돌려줄 범위 · 찾을 수 없음) · 조건 둘이면 겹침 | IF 겹치기 | 이름 정의 · 이름 관리자 · LAMBDA · 데이터 유효성 검사(고르기 목록) | IF 겹치기 · 순환 참조 · #CALC! | **수식 계산 — 밑줄 친 곳을 한 단계씩** · 조사식 창 | 실행 취소 · 파일 버전 기록 | **한 단계씩 계산 보기** | https://support.microsoft.com/ko-kr/excel/detect-formula-errors-in-excel · https://support.microsoft.com/ko-kr/office/xlookup-함수-b7fd680e-6d10-43e6-84f9-88eae8bf5929 · https://support.microsoft.com/en-us/office/lambda-function-bd212d27-1cd1-4321-a34a-ccbf254b8b67 | +| Airtable | 연결 레코드 → 조회 칸에서 가져올 칸 고름 · 조건 거름 | IF 수식 | `{칸 이름}` | 결과가 목록이라 바로 못 셈 · 연결 안 돼 빈칸 · 종류 불일치 #ERROR | 색 · 괄호 짝 표시 | 레코드 값 기록만 · 수식 설정 기록 없음 | 0건 · 여러 건 경고 | https://support.airtable.com/docs/formula-field-overview · https://support.airtable.com/docs/lookup-field-overview | +| Notion(한국어판) | 관계형 · 롤업 | if · ifs · 삼항 | 속성 · let()(중간값) | 「종류 불일치」 · 빈 값 처리 | **편집하며 그 행 결과 즉시 · 오류 자리 표시** | 못 찾음 | 견본 행 결과 즉시 | https://www.notion.com/help/formulas · https://www.notion.com/ko/help/formula-syntax · https://www.notion.com/help/common-formula-errors | +| Coda | 관계 칸(옛 이름 조회 칸) · Filter() | If · Filter | 이름 붙인 수식을 변수처럼 | Filter 이어 쓰기 문법 | 못 찾음 | 문서 기록은 복사만 · 복원은 지원 요청 | **결과에 이름 붙여 넘기기** | https://help.coda.io/formulas/using-lookups | +| Glide | 관계 칸(짝 칸) → 조회 칸(가져올 칸) | **만약 → 그러면 → 아니면 줄 목록 · 처음 맞는 줄** | 계산 칸에 「price + shipping」 처럼 말로 쓰고 낱말마다 실제 칸 짝 | 줄 차례 실수 · 조건 칸 안에서는 셈 못 함 | 못 찾음 | 못 찾음 | **조건 줄 쌓기 + 「말로 쓴 식 + 낱말 짝」** | https://www.glideapps.com/docs/automation/computed-columns/if-then-else-column · https://www.glideapps.com/docs/automation/computed-columns/math-column · https://www.glideapps.com/docs/automation/computed-columns/lookup-column | +| ConvertCalculator | 데이터시트(작은 표) · FINDIFS(값 칸, 조건 칸1, 입력1, …) 정확히 맞는 첫 줄 | IF · IFS | 질문 QA · 변수 VA | 정의 차례(뒤 것 부르면 오류) · 줄 번호 0부터 | 결과 미리 보기 · 오류 표시 | 못 찾음 | **여러 조건으로 표 한 줄** — 뒷길이 × 돌 × 쌓기와 같은 꼴 | https://www.convertcalculator.com/help/building/datasheets/ · https://www.convertcalculator.com/help/building/variables/ | +| Outgrow | 못 찾음 | 삼항 `Q1<4.5 ? 50 : 90` 겹침 | 질문 끌어 놓으면 Q1 · Q2 | 괄호 겹치기 | 못 찾음 | 못 찾음 | 끌어 놓기 입력 · 삼항은 피함 | https://support.outgrow.co/docs/create-calculator-using-conditional-logic | +| Salesforce CPQ 조회 질의 | 조회 개체 + 조회 질의(시험할 칸 · 비교 · 조회 칸) → 가져올 칸 | 가격 조건 줄(기준 종류 = 값 · 변수 · 수식) | 견적 칸 · 요약 변수 | 2줄 이상이면 실패 · 문법 검사 단추 없음 | 임시 수식 칸으로 우회 | 못 찾음 | **「찾을 칸 ↔ 표 칸」 짝 + 「정확히 1줄」 검사** | https://trailhead.salesforce.com/content/learn/modules/price-rules-in-salesforce-cpq/consolidate-price-rules-by-using-lookup-queries · 사진 res.cloudinary.com/hy4kyit2a/f_auto/fl_lossy/q_70/learn/modules/price-rules-in-salesforce-cpq/consolidate-price-rules-by-using-lookup-queries/images/84164c812826823c1b9e557360507574_kix.uodfoac0wvp9.png | + +## 3. 블록 · 흐름(끼우기 · 잇기) + +| 도구 | 조회(표) 연결 | 조건 | 변수 · 입력 정의 | 초보가 막히는 곳 | 검증 | 되돌리기 · 이력 | 우리 판에 가져올 것 | 사진 · 설명 주소 | +|---|---|---|---|---|---|---|---|---| +| Scratch(한국어) | 표 없음 · 리스트 항목뿐 | 「만약 ~라면」 블록 빈칸에 참 · 거짓 끼움 | 「변수 만들기」 → 「~로 정하기」 | 블록이 늘면 큰 식 · 복합 조건이 번거로움 | 블록 누르면 바로 실행 | 못 찾음 | 빈칸에 끼우는 모양 = 조건 칸 생김새 | https://test.scratch-wiki.info/wiki/Kor:변수 | +| Blockly | lists 블록(표 조회 없음) | if / else 블록 | 변수 get / set · 기본은 형식 없음 | 식 · 복합 조건이 길어짐 | 만든 쪽 몫 | 만든 쪽 몫 | **그림자 블록 — 칸에 기본값이 들어 있고 숫자를 바로 침** | https://docs.blockly.com/guides/configure/toolboxes/preset/ | +| Node-RED | Change 노드(JSONata) · Function(JS) | Switch 노드 규칙 · otherwise | msg 속성 · Inject | 조금만 복잡해도 JSONata · JS 로 넘어감 | Debug 노드 → 옆 패널 · Inject 단추 | 편집기 닫으면 실행 취소 사라짐 | 칸마다 값이 바로 보이게 | https://nodered.org/docs/user-guide/nodes · 사진 nodered.org/docs/user-guide/images/node_switch.png | +| n8n | Merge 「맞는 칸으로 합치기」 | IF · Switch(규칙 / 식 · Fallback 출구) | 칸마다 고정 / 식 전환 · `{{ $json.x }}` | 식은 결국 JS 조각 · 형식 다르면 소리 없이 빠짐 | **Test step · 데이터 고정(pin) · 지난 실행 불러오기** | 실행 기록 · 버전 기록 따로 | **견본 입력 고정 · 고칠 때마다 다시 셈** | https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.switch | +| Zapier | **Formatter 「Lookup Table」 — 왼쪽 키 · 오른쪽 값 · Fallback 값** | Paths(and / or · 왼쪽부터) · Filter | 앞 단계 출력 칸 고름 | 완전 일치 · 대소문자 — 안 맞으면 Fallback | Test step | 판 되돌리기(유료만) | **못 찾았을 때 쓸 값 칸** | https://help.zapier.com/hc/en-us/articles/8496305146765 · 사진 cdn.zapier.com/hc/article_attachments/31169646468749 | +| Make | 못 찾음 | Router + 갈래 Filter · 위 → 아래 · fallback 갈래 | 앞 모듈 결과 끌어 잇기 | 갈래 차례 · 겹친 조건 | Run once · 지난 실행 다시 | **저장마다 판 · 60일 · 저장 안 한 것도 복구** | 조건 갈래 차례 + 「그 밖」 | https://help.make.com/router · https://help.make.com/restore-a-previous-scenario-version | +| Power Automate | 표 조회 블록 없음 | Condition 카드(줄 · and / or) → 「Edit in advanced mode」 | 번개 아이콘 동적 값 | 조금만 복잡해도 `@and(equals(…))` 식이 다시 나옴 | Test → 실행 결과 | 못 찾음 | 쉬운 화면 조건을 글로 보여 주되 글로 고치게는 안 함 | https://learn.microsoft.com/en-us/power-automate/use-expressions-in-conditions | +| Unreal Blueprints(한국어 문서 있음) | **Get Data Table Row — 표 · 줄 이름 → Row Found / Row Not Found 두 출구** | Branch(분기) | 변수 · 입력 / 출력 핀 · 핀 기본값 | **「말 그대로 스파게티」 · 몇 줄 식이 노드 30~40개 · 정리가 따로 배울 기술** | 중단점 · 한 단계 실행 · 값 지켜보기 | Ctrl+Z · 판은 바깥 도구 | **Math Expression — 식을 글로 치면 핀이 저절로** | https://dev.epicgames.com/documentation/en-us/unreal-engine/BlueprintAPI/Utilities/GetDataTableRow · 사진 d1iv7db44yhgxn.cloudfront.net/documentation/images/f44ed18c-0c2e-4aea-8c18-815b5850e7ee/mathnodeexample.png | +| Unity Visual Scripting | — | If · Formula | Formula 인수 A · B …(최대 10) | 인수 이름이 A · B 라 뜻 모름 | 실행 중 포트 값 | 편집기 실행 취소 | 식 한 칸 + 입력 칸 목록 · 이름은 뜻 있는 말로 | https://docs.unity3d.com/Packages/com.unity.visualscripting@1.9/manual/vs-formula.html | + +블록 · 흐름 방식이 수식 · 규칙 정의에 쓰일 때 + +- 좋음 — 문법 오류가 안 생김(끼워지는 칸만 끼워짐) · 갈래와 「못 찾음」 출구가 눈에 보여 빠뜨리기 어려움 · 칸마다 중간값 보이기 좋음. +- 나쁨 — 산술식은 블록 · 노드로 만들면 폭발적으로 늘어남(Unreal · Unity 도 결국 「식 한 칸」 노드를 둠) · 선 정리가 또 하나의 일(스파게티 · 스크롤 · 검색 어려움) · 복잡해지면 식(JSONata · `{{ }}` · `@and(…)`)이 다시 나오고 초보는 거기서 막힘. +- 우리 판 뜻 — 흐름 그림은 「어느 결과가 어느 줄로 가나」 정도의 읽기만 · 만들기는 카드 + 문장 칩. + +## 4. 낱말 대조 + +| 도구 | 입력 | 고정값 | 결과 | 조회 | 조건 | +|---|---|---|---|---|---| +| DMN(Camunda · Trisotech · Drools) | Input / Input Data | Literal(상수 칸 따로 없음) | Output | Decision Table + Hit Policy | Input Entry(unary test) | +| Oracle IA | Base attribute | Value | Conclusion | Rule table | Condition | +| Guidewire | Parameter | Factor(요율계수) | Rate / Premium | Rate table | Match(일치 · 범위) | +| Salesforce CPQ | Tested field | Value | Target field | Lookup query / object | Price condition | +| Workday | Source field | Default value | Return value | Lookup Range Band | Condition | +| 시프티(한국어) | 근로정보 · 시급 | 시급(적용 시점) | 실급여정산 | — | — | +| Excel(한국어) | 셀 · 이름 상자 · 데이터 유효성 검사 | 상수 · 이름 정의 | 수식 결과 | XLOOKUP(찾을 값 · 찾을 범위 · 돌려줄 범위) | IF | +| Notion(한국어) | 속성 | 값 | 수식 | 관계형 · 롤업 | if · ifs | +| Glide | 칸 · 사용자 정보 | 짝지은 값 | 계산 칸 | 관계 → 조회 | 만약 · 그러면 · 아니면 | +| ConvertCalculator | 질문(QA) | 변수(VA) | 수식 | 데이터시트 · FINDIFS | IF · IFS | +| Scratch(한국어) | 변수(「변수 만들기」) | 칸에 바로 쓴 숫자 | 값 블록 | 리스트 항목 | 만약 ~라면 / 아니면 | +| Zapier | Lookup Key | Value | step output | Lookup Table | Paths · Filter / Fallback value | +| n8n | input item | Fixed | output | Merge(맞는 칸) | IF · Switch / Fallback | +| Unreal(한국어) | 변수 · 입력 핀 | 기본값 · Literal | 출력 핀 · Return Value | Get Data Table Row | Branch(분기) | + +우리 판 낱말 후보(브레인 · 사용자 결정 몫) — 입력 = **설계 때 고름** · 고정값 = **늘 이 값** · 결과 = **꺼낼 값**(표) / **결과**(로직) · 조회 = **표에서 찾기** · 조건 = **만약 ~면 / 그 밖** · 빈 조건 = **상관없음** · 못 찾음 = **맞는 줄 없음** · 여러 줄 = **두 줄이 걸림**. 제안서 낱말(받는 값 · 표에서 꺼냄 · 셈 · 줄로 보냄)과 겹치지 않게 하나로 맞출 것. + +## 5. 제안서(세 칸 판)에 비춰 본 것 + +- 맞음 — 표를 왼쪽에 그대로 두고 머리 칸에 역할(설계 때 고름 / 꺼낼 값)을 색으로 붙임 = OPA 칸 서식 · DMN 입력 / 출력 칸과 같은 생각. 식 대신 칩 고르기 = Workday · Glide 와 같은 길. +- 더할 것 — ② 카드에 「맞는 줄 없음 / 두 줄이 걸림」 줄(0 · 1 · 2+ 검사) · ③ 조건을 「위에서 처음 맞는 줄 + 그 밖」 목록으로 · 「빈틈 검사」 단추(설계 때 고르는 값 조합 가운데 표에 없는 것) · 기대값 시험표(원문 숫자 → 실제 → 통과 / 실패) · 가운데 칸 단계마다 값(계산 과정 단계표). +- 조심할 것 — 할증 · 나누기 같은 산술을 블록으로 쪼개지 않음(한 문장 틀 + 숫자 칩) · 「고급」 으로 식을 다시 꺼내는 길을 두지 않음 · 판 · 전후 비교 · 적용 시점(품셈 연도판)은 처음부터 자리를 잡음(도구 대부분이 설정 이력이 약해 뒤에 붙이기 어려움). From 4f12eb36114a677544896afa39dc787fbc1ea50f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:43:52 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?Check=20=EC=B4=88=EC=95=88=20=E2=80=94=20=EB=B9=88=ED=8B=88=20?= =?UTF-8?q?=C2=B7=20=EC=A4=91=EB=B3=B5=20=C2=B7=20=EA=B1=B8=EB=A6=B0=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B8=B0=EB=8C=80=EA=B0=92=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=ED=91=9C(PLAN=2035-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 찾기는 엔진 _match · find 규칙 그대로(범위규칙 넷 · 빈 칸 무관 · 위 끝 가장 작은 줄 · 결과 칸 있는 줄) - 시험 test_m01_lb_check.py 7 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 274 ++++++++++++++++++ resources/tester/test_m01_lb_check.py | 290 ++++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Check.ts create mode 100644 resources/tester/test_m01_lb_check.py diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts new file mode 100644 index 000000000..b62485477 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -0,0 +1,274 @@ +/* ============================================================================= + * M01_LB_Check.ts + * 로직 만들기 판 — 검사(정함 7 · 설계초안 4절) + * ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈) + * ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복 + * ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패 + * 시험표 줄은 sessionStorage 초안만(저장 안 함) + * + * 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 · + * 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈. + * DOM 없음. + * ========================================================================== */ + +import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; + +/* ── 맞출 모양(설계초안 7-1 의 쓰는 칸만) ─────────────────────────────── */ + +type Ref = + | { k: "input"; name: string } + | { k: "fixed"; value: number | string } + | { k: string; [x: string]: unknown }; + +export interface CheckLookup { + id: string; + table: string; + conds: { col: string; from: Ref }[]; + col: string; +} + +export interface CheckLogic { + inputs: LogicInput[]; + lookups: CheckLookup[]; +} + +type Cell = string | number | boolean | null | [number | null, number | null]; +export interface CheckTable { + 키?: string; + 범위규칙?: string; + 조건?: Record; + 값칸?: Record; + 줄: Record[]; +} + +/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */ + +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +function match( + table: CheckTable, + row: Record, + cond: string, + want: unknown, +): boolean { + if (!(cond in row)) return true; // 빈 조건 칸 = 무관 + const have = row[cond] as Cell; + if (table.조건?.[cond] === "범위") { + if (!Array.isArray(have) || !isNum(want)) return false; + const [low, high] = have; + const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · "); + const rule = head.split(" (")[0]; + const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low); + if (rule === "이상·이하") return above(false) && (high === null || want <= high); + if (rule === "초과·이하" || rule === "이하") + return above(true) && (high === null || want <= high); + if (rule === "이상·미만") { + const tops = table.줄 + .map((r) => r[cond]) + .filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null) + .map((c) => c[1]); + const closed = + more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops); + return above(false) && (high === null || want < high || (closed && want === high)); + } + return false; // 모르는 범위규칙 — 엔진은 멈춤 + } + if (isNum(have) && isNum(want)) return have === want; + return typeof have === "string" && typeof want === "string" && have === want; +} + +/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */ +export function findRows(table: CheckTable, conds: Record, want = ""): number[] { + let hits = table.줄 + .map((row, i) => ({ row, i })) + .filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v))); + const first = (table.범위규칙 ?? "").split(" · ")[0]; + if (hits.length > 1 && (first === "이하" || first === "이상·이하")) { + for (const c of Object.keys(conds)) { + if (table.조건?.[c] !== "범위") continue; + const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]); + if (!ups.length || ups.includes(null)) continue; + const low = Math.min(...(ups as number[])); + hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low); + } + } + if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row); + return hits.map((h) => h.i); +} + +/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */ + +export interface Gap { + table: string; + /** 어느 표 조회에서 나왔나(화면 번호) */ + lookup: string; + conds: Record; + kind: "없음" | "중복"; + /** 중복이면 걸린 줄 차례 */ + rows: number[]; +} + +/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ +function combos(lb: CheckLogic, lk: CheckLookup): Record[] | null { + const lists: [string, unknown[]][] = []; + for (const { col, from } of lk.conds) { + if (from.k === "fixed") lists.push([col, [from.value]]); + else if (from.k === "input") { + const spec = lb.inputs.find((x) => x.이름 === from.name); + if (!spec?.고르기?.length) return null; + lists.push([col, spec.고르기]); + } else return null; + } + let out: Record[] = [{}]; + for (const [col, values] of lists) + out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v }))); + return out; +} + +/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ +export function gaps(lb: CheckLogic, tables: Record): Gap[] { + const out: Gap[] = []; + const seen = new Set(); + for (const lk of lb.lookups) { + const table = tables[lk.table] as CheckTable | undefined; + const sets = table?.줄 ? combos(lb, lk) : null; + if (!table || !sets) continue; + for (const conds of sets) { + const rows = findRows(table, conds, lk.col); + if (rows.length === 1) continue; + const tag = `${lk.table}|${lk.col}|${JSON.stringify(conds)}`; + if (seen.has(tag)) continue; + seen.add(tag); + out.push({ + table: lk.table, + lookup: lk.id, + conds, + kind: rows.length ? "중복" : "없음", + rows, + }); + } + } + return out; +} + +/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ +export function gapSkipped(lb: CheckLogic, tables: Record): string[] { + return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); +} + +/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */ + +/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */ +export type LbHit = number | null | "없음" | "중복"; + +export function lookupHits( + lb: CheckLogic, + tables: Record, + values: Record, +): Record { + const out: Record = {}; + for (const lk of lb.lookups) { + const table = tables[lk.table] as CheckTable | undefined; + const conds: Record = {}; + let known = !!table?.줄; + for (const { col, from } of lk.conds) { + if (from.k === "fixed") conds[col] = from.value; + else if (from.k === "input" && values[from.name as string] !== undefined) + conds[col] = values[from.name as string]; + else known = false; + } + if (!known || !table) { + out[lk.id] = null; + continue; + } + const rows = findRows(table, conds, lk.col); + out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음"; + } + return out; +} + +/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */ + +export interface ExpectRow { + /** 예시값 */ + inputs: Record; + /** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */ + expect: Record; +} + +export interface ExpectResult { + ok: boolean; + got: Record; + /** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */ + why: string | null; +} + +/** 계산 답 → 볼 값 모음 */ +export function pickValues(ans: CalcAnswer): Record { + const got: Record = {}; + if (!ans.ok) return got; + if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"]; + if (isNum(ans.result)) got["결과"] = ans.result; + for (const line of ans.lines ?? []) { + if (isNum(line.수량)) got[line.이름] = line.수량; + if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액; + } + return got; +} + +/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */ +export function sameAsExpect(got: number | undefined, expect: number): boolean { + if (!isNum(got)) return false; + const places = (String(expect).split(".")[1] ?? "").length; + return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9; +} + +type CalcFn = ( + row: LogicRow, + inputs: Record, + tables: Record, +) => Promise; + +/** 줄마다 시험 계산(차례로 · 하나씩) — `row` 는 블록을 글로 되돌린 로직 줄 · `calc` 는 LB Api 의 바로 계산 */ +export async function runExpect( + row: LogicRow, + tables: Record, + rows: ExpectRow[], + calc: CalcFn, +): Promise { + const out: ExpectResult[] = []; + for (const r of rows) { + try { + const ans = await calc(row, r.inputs, tables); + const got = pickValues(ans); + const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v)); + out.push({ ok, got, why: ans.ok ? null : ans.reason }); + } catch (e) { + out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) }); + } + } + return out; +} + +/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */ + +const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`; + +export function loadExpectRows(logic: string): ExpectRow[] { + try { + const raw = sessionStorage.getItem(draftKey(logic)); + const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : []; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +export function saveExpectRows(logic: string, rows: ExpectRow[]): void { + try { + if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows)); + else sessionStorage.removeItem(draftKey(logic)); + } catch { + /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */ + } +} diff --git a/resources/tester/test_m01_lb_check.py b/resources/tester/test_m01_lb_check.py new file mode 100644 index 000000000..58a88465f --- /dev/null +++ b/resources/tester/test_m01_lb_check.py @@ -0,0 +1,290 @@ +"""로직 만들기 판 — 검사(`M01_LB_Check.ts`)의 빈틈 · 중복 · 걸린 줄 · 기대값 맞춤. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 서버는 빈 흉내 · 순수 함수만). +견본 표 · 로직은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Check.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 stub = new Proxy({}, { get: () => () => undefined }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +Promise.all( + jobs.map(async (job) => { + if (job.op === "gaps") return m.gaps(job.lb, job.tables); + if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables); + if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values); + if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? ""); + if (job.op === "same") return m.sameAsExpect(job.got, job.expect); + if (job.op === "expect") { + const answers = job.answers; + let i = 0; + return m.runExpect({}, {}, job.rows, async () => answers[i++]); + } + return null; + }), +).then((out) => 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) + + +# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관 +STONE = { + "키": "T1", + "조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, + "값칸": {"석공": "인"}, + "줄": [ + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15}, + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13}, + {"뒷길이": 25, "돌": "호박돌", "석공": 0.10}, + {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22}, + {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복 + ], +} + +# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함) +HEIGHT = { + "키": "T2", + "범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함", + "조건": {"높이": "범위"}, + "값칸": {"증가율": "%"}, + "줄": [ + {"높이": [None, 2], "증가율": 0}, + {"높이": [2, 4], "증가율": 30}, + {"높이": [4, 6], "증가율": 40}, + ], +} + +# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄) +UPTO = { + "키": "T3", + "범위규칙": "이하", + "조건": {"거리": "범위"}, + "값칸": {"값": ""}, + "줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}], +} + +TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO} + + +def lk(id_, table, conds, col): + return {"id": id_, "table": table, "conds": conds, "col": col} + + +LOGIC = { + "inputs": [ + {"이름": "뒷길이", "고르기": [25, 30]}, + {"이름": "돌", "고르기": ["깬잡석", "호박돌"]}, + {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, + {"이름": "높이", "고르기": [1, 3, 6, 7]}, + {"이름": "길이"}, # 숫자 입력 — 선택 목록 없음 + ], + "lookups": [ + lk( + "a", + "T1", + [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "input", "name": "돌"}}, + {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, + ], + "석공", + ), + lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"), + lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"), + ], +} + + +def by(gaps, lookup): + return sorted( + ( + (g["kind"], json.dumps(g["conds"], ensure_ascii=False)) + for g in gaps + if g["lookup"] == lookup + ) + ) + + +def test_빈틈_중복_무관_빈칸(): + [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) + got = by(gaps, "a") + # 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합 + # 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님) + # 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜 + # 중복 — 30 · 깬잡석 · 골쌓기(두 줄) + assert got == sorted( + [ + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False), + ), + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False), + ), + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False), + ), + ( + "중복", + json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False), + ), + ] + ) + dup = next(g for g in gaps if g["kind"] == "중복") + assert dup["rows"] == [3, 4] + + +def test_범위_줄_이상미만_마지막칸_이하포함(): + [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) + # 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음 + assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))] + out = run( + [ + {"op": "find", "table": HEIGHT, "conds": {"높이": 2}}, + {"op": "find", "table": HEIGHT, "conds": {"높이": 4}}, + {"op": "find", "table": HEIGHT, "conds": {"높이": 6}}, + ] + ) + assert out == [[1], [2], [2]] + + +def test_범위_이하_위끝_가장_작은_줄(): + out = run( + [ + {"op": "find", "table": UPTO, "conds": {"거리": 5}}, + {"op": "find", "table": UPTO, "conds": {"거리": 15}}, + {"op": "find", "table": UPTO, "conds": {"거리": 25}}, + ] + ) + assert out == [[0], [1], []] + + +def test_셀_수_없는_조회는_건너뜀(): + gaps, skipped = run( + [ + {"op": "gaps", "lb": LOGIC, "tables": TABLES}, + {"op": "skipped", "lb": LOGIC, "tables": TABLES}, + ] + ) + assert by(gaps, "c") == [] + assert skipped == ["c"] + + +def test_예시값으로_걸린_줄(): + [hits] = run( + [ + { + "op": "hits", + "lb": LOGIC, + "tables": TABLES, + "values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15}, + } + ] + ) + assert hits == {"a": "중복", "b": 1, "c": 1} + [hits] = run( + [ + { + "op": "hits", + "lb": LOGIC, + "tables": TABLES, + "values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, + } + ] + ) + assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름 + + +def test_고정값_조건(): + lb = { + "inputs": [], + "lookups": [ + lk( + "f", + "T1", + [ + {"col": "돌", "from": {"k": "fixed", "value": "호박돌"}}, + {"col": "뒷길이", "from": {"k": "fixed", "value": 25}}, + ], + "석공", + ), + lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"), + ], + } + gaps, hits = run( + [ + {"op": "gaps", "lb": lb, "tables": TABLES}, + {"op": "hits", "lb": lb, "tables": TABLES, "values": {}}, + ] + ) + assert gaps == [] + assert hits == {"f": 2, "g": None} + + +def test_기대값_시험표(): + answers = [ + { + "ok": True, + "lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}], + "sums": {"계": 52437.06}, + "middle": {}, + }, + { + "ok": True, + "lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}], + "sums": {"계": 50000}, + "middle": {}, + }, + {"ok": False, "reason": "표에 줄이 없음"}, + ] + rows = [ + {"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}}, + {"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}}, + {"inputs": {"뒷길이": 99}, "expect": {"계": 1}}, + ] + [res] = run([{"op": "expect", "rows": rows, "answers": answers}]) + assert [r["ok"] for r in res] == [True, False, False] + assert res[0]["got"]["석공 금액"] == 52437.06 + assert res[2]["why"] == "표에 줄이 없음" + same = run( + [ + {"op": "same", "got": 52437.4, "expect": 52437}, + {"op": "same", "got": 52437.6, "expect": 52437}, + {"op": "same", "got": 0.264, "expect": 0.26}, + ] + ) + assert same == [True, False, True] From f62689dff1c6db7989a9eec926f870a78fc31f5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:47:53 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EC=99=BC=EC=AA=BD=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20Source=20=C2=B7=20Check=20=EB=A5=BC=20LB?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=EC=97=90=20=EB=A7=9E=EC=B6=A4(PLAN=2035-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderSource: 표 이름 · 원문번호 · 기준 · 격자 · 역할 칩 · 걸린 줄 · 해당 없음/중복 · 칸 고침(on.edit) · 주 · 사용처 - Check: runExpect(lb, tables, rows) · 같은 조건 두 칸 조회는 빈틈 한 번 - 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 56 ++- M01_MasterData/LogicBuilder/M01_LB_Source.css | 196 +++++++++ M01_MasterData/LogicBuilder/M01_LB_Source.ts | 371 ++++++++++++++++++ resources/tester/test_m01_lb_check.py | 92 +++++ 4 files changed, 683 insertions(+), 32 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Source.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Source.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts index b62485477..075c8a9f4 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Check.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -11,26 +11,9 @@ * DOM 없음. * ========================================================================== */ -import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; - -/* ── 맞출 모양(설계초안 7-1 의 쓰는 칸만) ─────────────────────────────── */ - -type Ref = - | { k: "input"; name: string } - | { k: "fixed"; value: number | string } - | { k: string; [x: string]: unknown }; - -export interface CheckLookup { - id: string; - table: string; - conds: { col: string; from: Ref }[]; - col: string; -} - -export interface CheckLogic { - inputs: LogicInput[]; - lookups: CheckLookup[]; -} +import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks"; type Cell = string | number | boolean | null | [number | null, number | null]; export interface CheckTable { @@ -109,7 +92,7 @@ export interface Gap { } /** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ -function combos(lb: CheckLogic, lk: CheckLookup): Record[] | null { +function combos(lb: LbLogic, lk: LbLookup): Record[] | null { const lists: [string, unknown[]][] = []; for (const { col, from } of lk.conds) { if (from.k === "fixed") lists.push([col, [from.value]]); @@ -126,7 +109,7 @@ function combos(lb: CheckLogic, lk: CheckLookup): Record[] | nu } /** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ -export function gaps(lb: CheckLogic, tables: Record): Gap[] { +export function gaps(lb: LbLogic, tables: Record): Gap[] { const out: Gap[] = []; const seen = new Set(); for (const lk of lb.lookups) { @@ -136,7 +119,7 @@ export function gaps(lb: CheckLogic, tables: Record): Gap[] { for (const conds of sets) { const rows = findRows(table, conds, lk.col); if (rows.length === 1) continue; - const tag = `${lk.table}|${lk.col}|${JSON.stringify(conds)}`; + const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`; if (seen.has(tag)) continue; seen.add(tag); out.push({ @@ -152,7 +135,7 @@ export function gaps(lb: CheckLogic, tables: Record): Gap[] { } /** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ -export function gapSkipped(lb: CheckLogic, tables: Record): string[] { +export function gapSkipped(lb: LbLogic, tables: Record): string[] { return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); } @@ -162,7 +145,7 @@ export function gapSkipped(lb: CheckLogic, tables: Record): str export type LbHit = number | null | "없음" | "중복"; export function lookupHits( - lb: CheckLogic, + lb: LbLogic, tables: Record, values: Record, ): Record { @@ -173,8 +156,8 @@ export function lookupHits( let known = !!table?.줄; for (const { col, from } of lk.conds) { if (from.k === "fixed") conds[col] = from.value; - else if (from.k === "input" && values[from.name as string] !== undefined) - conds[col] = values[from.name as string]; + else if (from.k === "input" && values[from.name] !== undefined) + conds[col] = values[from.name]; else known = false; } if (!known || !table) { @@ -226,16 +209,25 @@ export function sameAsExpect(got: number | undefined, expect: number): boolean { type CalcFn = ( row: LogicRow, inputs: Record, - tables: Record, + tables: LbTables, ) => Promise; -/** 줄마다 시험 계산(차례로 · 하나씩) — `row` 는 블록을 글로 되돌린 로직 줄 · `calc` 는 LB Api 의 바로 계산 */ +/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */ +const calcOnce: CalcFn = (row, inputs, tables) => + new Promise((resolve, reject) => + lbCalcLater(row, inputs, tables, (ans, plain) => + ans ? resolve(ans) : reject(new Error(plain ?? "")), + ), + ); + +/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */ export async function runExpect( - row: LogicRow, - tables: Record, + lb: LbLogic, + tables: LbTables, rows: ExpectRow[], - calc: CalcFn, + calc: CalcFn = calcOnce, ): Promise { + const row = writeLogic(lb); const out: ExpectResult[] = []; for (const r of rows) { try { diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.css b/M01_MasterData/LogicBuilder/M01_LB_Source.css new file mode 100644 index 000000000..0d90c4871 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Source.css @@ -0,0 +1,196 @@ +/* 로직 만들기 판 — 왼쪽 원문 */ +.m01-lbsrc { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; + font-size: var(--text-body-sm); +} + +.m01-lbsrc p { + margin: 0; +} + +.m01-lbsrc__muted { + color: var(--color-text-muted); +} + +.m01-lbsrc__muted:empty { + display: none; +} + +.m01-lbsrc__preview { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas)); + color: var(--color-text); +} + +.m01-lbsrc__table { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + min-width: 0; +} + +.m01-lbsrc__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); +} + +.m01-lbsrc__users { + margin-left: auto; + padding: 0; + border: 0; + background: none; + color: var(--color-accent); + font: inherit; + cursor: pointer; + text-decoration: underline; +} + +.m01-lbsrc__warn { + color: var(--color-danger); + font-weight: 600; +} + +/* 엑셀 같은 격자 */ +.m01-lbsrc__grid { + max-height: 22rem; + overflow: auto; + border: 1px solid var(--color-border); +} + +.m01-lbsrc__grid table { + border-collapse: collapse; + width: 100%; +} + +.m01-lbsrc__grid th, +.m01-lbsrc__grid td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + white-space: nowrap; + text-align: left; +} + +.m01-lbsrc__grid thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-paper); + color: var(--color-text-secondary); + font-weight: 500; + vertical-align: top; +} + +.m01-lbsrc__grid thead th span { + display: block; +} + +.m01-lbsrc__grid .m01-lbsrc__no { + width: 2.5rem; + background: var(--color-paper); + color: var(--color-text-muted); + text-align: right; +} + +.m01-lbsrc__grid td { + cursor: cell; +} + +.m01-lbsrc__grid td.is-value { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-lbsrc__grid td.is-col { + font-weight: 700; +} + +.m01-lbsrc__grid tr.is-hit td { + background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); +} + +.m01-lbsrc__grid td.is-edited { + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +.m01-lbsrc__grid td.is-bad { + box-shadow: inset 0 0 0 2px var(--color-danger); +} + +.m01-lbsrc__input { + width: 100%; + min-width: 4rem; + padding: 0 var(--spacing-4); + border: 1px solid var(--color-accent); + font: inherit; + background: var(--color-canvas); + color: var(--color-text); +} + +/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */ +.m01-lbsrc__chip { + margin-top: var(--spacing-4); + padding: 0 var(--spacing-8); + border: 1px solid currentColor; + border-radius: 999px; + background: transparent; + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} + +.m01-lbsrc__chip.is-cond { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.m01-lbsrc__chip.is-fixed { + color: var(--color-text-secondary); +} + +.m01-lbsrc__chip.is-result { + color: var(--color-success, #2f6b4f); + background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent); +} + +.m01-lbsrc__chip.is-free { + color: var(--color-text-muted); + border-style: dashed; +} + +.m01-lbsrc__notes summary { + cursor: pointer; + color: var(--color-text-secondary); +} + +.m01-lbsrc__notes ol { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); + list-style: none; + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-lbsrc__notes li.is-used { + font-weight: 600; +} + +.m01-lbsrc__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-4); + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + color: var(--color-accent); + font-size: var(--text-caption); + font-weight: 500; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.ts b/M01_MasterData/LogicBuilder/M01_LB_Source.ts new file mode 100644 index 000000000..8a16e89a9 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Source.ts @@ -0,0 +1,371 @@ +/* ============================================================================= + * M01_LB_Source.ts + * 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절) + * 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과) + * · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처. + * 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { tableUsers, type LbTables } from "./M01_LB_Api"; +import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks"; +import type { LbHit } from "./M01_LB_Check"; +import "./M01_LB_Source.css"; + +const TEXT = { + Preview: [ + "표 고침은 미리 보기 — 저장은 결정 뒤", + "Table edits are a preview — saving comes later", + ], + Users: ["사용처", "Used by"], + UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"], + UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"], + UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"], + Loading: ["불러오는 중", "Loading"], + Missing: ["표를 불러오지 못함", "Couldn't load the table"], + NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"], + DupRow: [ + "중복 — 예시값에 맞는 줄이 둘 이상", + "Duplicate — more than one row fits the sample values", + ], + Notes: ["주", "Notes"], + NoteUsed: ["계산에 쓰임", "Used in the calculation"], + Cond: ["조건", "Condition"], + Fixed: ["고정", "Fixed"], + Result: ["결과", "Result"], + Free: ["무관", "Any"], + RoleHint: ["눌러 역할 바꿈", "Tap to change the role"], + EditHint: [ + "칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소", + "Tap a cell to edit · Enter to finish · Esc to cancel", + ], + Edited: ["고친 칸", "Edited cell"], +} as const; + +const ts = (key: keyof typeof TEXT, n?: number): string => + TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? "")); + +export type LbRole = "조건" | "고정" | "결과"; +type Cell = string | number | boolean | null | [number | null, number | null] | undefined; + +interface SourceTable { + 키?: string; + 이름?: string; + 원문번호?: string; + 기준?: string; + 범위규칙?: string; + 조건?: Record; + 값칸?: Record; + 주?: string[]; + 용도?: { 로직키?: string[] }; + 줄: Record[]; +} + +export interface SourceOn { + edit(e: TableEdit): void; + role(table: string, col: string, role: LbRole): void; +} + +/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */ + +const isRange = (v: unknown): v is [number | null, number | null] => + Array.isArray(v) && v.length === 2; + +/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */ +export function showCell(v: Cell): string { + if (v === null || v === undefined) return ""; + if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; + return String(v); +} + +/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */ +export function parseCell(text: string, before: Cell): TableEdit["value"] | null { + const t = text.trim(); + const num = (s: string): number | null | undefined => { + const c = s.replace(/,/g, "").trim(); + if (!c) return null; + return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined; + }; + if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) { + const parts = t.split(/[~~]/); + if (parts.length !== 2) return null; + const [a, b] = parts.map(num); + if (a === undefined || b === undefined || (a === null && b === null)) return null; + if (a !== null && b !== null && a > b) return null; + return [a, b]; + } + if (!t) return null; + const n = num(t); + if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) { + return typeof n === "number" ? n : null; + } + return t; +} + +/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */ + +/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */ +export function roleOf(lookups: LbLookup[], col: string): LbRole | null { + for (const lk of lookups) { + if (lk.col === col) return "결과"; + const c = lk.conds.find((x) => x.col === col); + if (c) return c.from.k === "fixed" ? "고정" : "조건"; + } + return null; +} + +const NEXT: Record = { 조건: "고정", 고정: "결과", 결과: "조건" }; + +/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */ +export function usedNotes(row: unknown): Map> { + const out = new Map>(); + const add = (where: string, mark: string): void => { + const set = out.get(where) ?? new Set(); + set.add(mark); + out.set(where, set); + }; + const walk = (v: unknown): void => { + if (typeof v === "string") { + for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]); + } else if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === "object") Object.values(v).forEach(walk); + }; + walk(row); + return out; +} + +/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */ +function hitsOf( + key: string, + lookups: LbLookup[], + hits: Record, +): { rows: Set; none: boolean; dup: boolean } { + const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null); + return { + rows: new Set(got.filter((h): h is number => typeof h === "number")), + none: got.includes("없음"), + dup: got.includes("중복"), + }; +} + +/* ── 그리기 ─────────────────────────────────────────────────────── */ + +/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */ +const originals = new Map(); + +function snapshot(key: string, table: SourceTable, cols: string[]): string[][] { + const had = originals.get(key); + if (had && had.length === table.줄.length) return had; + const shot = table.줄.map((r) => cols.map((c) => showCell(r[c]))); + originals.set(key, shot); + return shot; +} + +function roleChip( + key: string, + col: string, + role: LbRole | null, + isCond: boolean, + on: SourceOn, +): HTMLElement { + const shown = role ?? (isCond ? null : undefined); + if (shown === undefined) return el("span"); + const label = shown + ? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result") + : ts("Free"); + const chip = el("button", { + className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`, + text: label, + attrs: { type: "button", title: ts("RoleHint") }, + }); + chip.addEventListener("click", (ev) => { + ev.stopPropagation(); + on.role(key, col, shown ? NEXT[shown] : "조건"); + }); + return chip; +} + +function editCell( + td: HTMLTableCellElement, + before: Cell, + done: (v: TableEdit["value"]) => void, +): void { + if (td.querySelector("input")) return; + const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } }); + input.value = showCell(before); + const text = td.textContent ?? ""; + td.replaceChildren(input); + input.focus(); + input.select(); + let closed = false; + const close = (save: boolean): void => { + if (closed) return; + closed = true; + const value = save ? parseCell(input.value, before) : null; + const same = value !== null && showCell(value) === showCell(before); + td.textContent = value === null || same ? text : showCell(value); + td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text); + if (value !== null && !same) done(value); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") close(true); + if (ev.key === "Escape") close(false); + ev.stopPropagation(); + }); + input.addEventListener("blur", () => close(true)); +} + +function renderTable( + key: string, + table: SourceTable | undefined, + lb: LbLogic, + hits: Record, + notes: Map>, + on: SourceOn, +): HTMLElement { + const lookups = lb.lookups.filter((lk) => lk.table === key); + if (!table?.줄) { + return el("section", { + className: "m01-lbsrc__table", + children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })], + }); + } + const conds = Object.keys(table.조건 ?? {}); + const vals = Object.keys(table.값칸 ?? {}); + const cols = [...conds, ...vals]; + const orig = snapshot(key, table, cols); + const hit = hitsOf(key, lookups, hits); + + const users = (table.용도?.로직키 ?? []).length; + const userBtn = el("button", { + className: "m01-lbsrc__users", + text: users ? ts("UsersN", users) : ts("UsersNone"), + attrs: { type: "button" }, + }); + const userList = el("p", { className: "m01-lbsrc__muted" }); + userBtn.addEventListener("click", () => { + userList.textContent = ts("Loading"); + tableUsers(key) + .then((list) => { + userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone"); + userList.textContent = list.map((u) => u.이름).join(" · "); + }) + .catch(() => (userList.textContent = ts("UsersFail"))); + }); + + const head = el("tr", { + children: [ + el("th", { className: "m01-lbsrc__no", text: "" }), + ...cols.map((c) => { + const unit = table.값칸?.[c]; + return el("th", { + className: vals.includes(c) ? "is-value" : "", + children: [ + el("span", { text: unit ? `${c} (${unit})` : c }), + roleChip(key, c, roleOf(lookups, c), conds.includes(c), on), + ], + }); + }), + ], + }); + const body = table.줄.map((row, i) => { + const tr = el("tr", { + className: hit.rows.has(i) ? "is-hit" : "", + children: [ + el("td", { className: "m01-lbsrc__no", text: String(i + 1) }), + ...cols.map((c, j) => { + const td = el("td", { + className: [ + vals.includes(c) ? "is-value" : "", + lookups.some((lk) => lk.col === c) ? "is-col" : "", + showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "", + ].join(" "), + text: showCell(row[c]), + }); + td.addEventListener("click", () => + editCell(td, row[c], (value) => { + td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]); + on.edit({ table: key, row: i, col: c, value }); + }), + ); + return td; + }), + ], + }); + return tr; + }); + + const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]); + const noteList = (table.주 ?? []).map((n) => { + const used = marks.has(n.trim().charAt(0)); + return el("li", { + className: used ? "is-used" : "", + children: [ + el("span", { text: n }), + ...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []), + ], + }); + }); + + return el("section", { + className: "m01-lbsrc__table", + children: [ + el("div", { + className: "m01-lbsrc__head", + children: [ + el("strong", { text: table.이름 ?? "" }), + el("span", { + className: "m01-lbsrc__muted", + text: [table.원문번호, table.기준].filter(Boolean).join(" · "), + }), + userBtn, + ], + }), + userList, + ...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []), + ...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []), + el("div", { + className: "m01-lbsrc__grid", + children: [ + el("table", { + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ], + }), + ...(noteList.length + ? [ + el("details", { + className: "m01-lbsrc__notes", + attrs: marks.size ? { open: "" } : {}, + children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })], + }), + ] + : []), + ], + }); +} + +/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */ +export function renderSource( + host: HTMLElement, + lb: LbLogic, + tables: LbTables, + hits: Record, + on: SourceOn, +): void { + const keys = [...new Set(lb.lookups.map((lk) => lk.table))]; + const notes = usedNotes(lb.doc?.row); + host.replaceChildren( + el("div", { + className: "m01-lbsrc", + children: [ + el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }), + el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }), + ...keys.map((k) => + renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on), + ), + ], + }), + ); +} diff --git a/resources/tester/test_m01_lb_check.py b/resources/tester/test_m01_lb_check.py index 58a88465f..a8fbeb80d 100644 --- a/resources/tester/test_m01_lb_check.py +++ b/resources/tester/test_m01_lb_check.py @@ -288,3 +288,95 @@ def test_기대값_시험표(): ] ) assert same == [True, False, True] + + +SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts" + +RUNNER_UI = 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 stub = new Proxy({}, { get: () => () => undefined }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "parse") return m.parseCell(job.text, job.before); + if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]); + if (job.op === "role") return m.roleOf(job.lookups, job.col); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run_ui(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_원문_칸_고친_글_읽기(): + out = run_ui( + [ + {"op": "parse", "text": "0.3", "before": 0.26}, + {"op": "parse", "text": "1,200", "before": 1000}, + {"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음 + {"op": "parse", "text": "3~4", "before": [2, 3]}, + {"op": "parse", "text": "~5", "before": [None, 4]}, + {"op": "parse", "text": "7~", "before": [6, None]}, + {"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음 + {"op": "parse", "text": "깬돌", "before": "깬잡석"}, + {"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로 + {"op": "parse", "text": "", "before": 1}, + ] + ) + assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None] + + +def test_계산에_쓰인_주와_칸_역할(): + [notes] = run_ui( + [ + { + "op": "notes", + "row": { + "중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}], + "비고": "주① 참고", + }, + } + ] + ) + assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]] + lookups = [ + { + "id": "a", + "table": "T1", + "col": "석공", + "conds": [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}}, + ], + } + ] + out = run_ui( + [ + {"op": "role", "lookups": lookups, "col": "석공"}, + {"op": "role", "lookups": lookups, "col": "뒷길이"}, + {"op": "role", "lookups": lookups, "col": "돌"}, + {"op": "role", "lookups": lookups, "col": "쌓기"}, + ] + ) + assert out == ["결과", "조건", "고정", None] From 271e15c80d2ccca1b5727afbf13bbbf046d79f73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:48:10 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0(Grid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엑셀처럼 줄 · 칸으로 보는 가운데 격자 — 고르기 · 숫자 칸 · 줄 누르면 앞 줄 칠함 · 표준은 읽기만. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/LogicBuilder/M01_LB_Grid.css | 109 ++++ M01_MasterData/LogicBuilder/M01_LB_Grid.ts | 541 ++++++++++++++++++++ 2 files changed, 650 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Grid.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Grid.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.css b/M01_MasterData/LogicBuilder/M01_LB_Grid.css new file mode 100644 index 000000000..03a75d0cb --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.css @@ -0,0 +1,109 @@ +.m01-lbgrid__scroll { + max-width: 100%; + max-height: 70vh; + overflow: auto; +} + +.m01-lbgrid__table { + width: 100%; + min-width: 720px; + border-collapse: separate; + border-spacing: 0; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-lbgrid__table th, +.m01-lbgrid__table td { + padding: var(--spacing-4) var(--spacing-8); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-lbgrid__table th:first-child, +.m01-lbgrid__table td:first-child { + border-left: 1px solid var(--color-border); +} + +.m01-lbgrid__table thead th { + position: sticky; + top: 0; + z-index: 1; + border-top: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-lbgrid__row { + cursor: pointer; +} + +.m01-lbgrid__row--first td { + border-top: 2px solid var(--color-border); +} + +.m01-lbgrid__row--sub .m01-lbgrid__name { + padding-left: var(--spacing-16); + color: var(--color-text-secondary); +} + +.m01-lbgrid__step { + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-lbgrid__table td.m01-lbgrid__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-lbgrid__src { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */ +.m01-lbgrid__row--on td { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + font-weight: 600; +} + +.m01-lbgrid__row--pre td { + background: color-mix(in srgb, var(--color-warning) 28%, transparent); +} + +.m01-lbgrid__row--pre td:first-child { + box-shadow: inset 4px 0 0 var(--color-warning); +} + +.m01-lbgrid__input { + box-sizing: border-box; + width: 96px; + 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); + text-align: right; +} + +.m01-lbgrid__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-lbgrid__note, +.m01-lbgrid__hint { + margin: var(--spacing-4) 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts new file mode 100644 index 000000000..2e69ecd3b --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts @@ -0,0 +1,541 @@ +/* ============================================================================= + * M01_LB_Grid.ts + * 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change + * 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값 + * 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만 + * 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; +import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model"; +import { + setCalc, + setCond, + type LbCalc, + type LbExpr, + type LbLogic, + type LbRef, +} from "./M01_LB_Blocks"; +import "./M01_LB_Grid.css"; + +const TEXT = { + Step1: ["① 입력값", "① Input"], + Step2: ["② 표 조회", "② Table lookup"], + Step3: ["③ 계산", "③ Calculation"], + Step4: ["④ 항목 연결", "④ Items"], + Step5: ["⑤ 결과값", "⑤ Result"], + HStep: ["단계", "Step"], + HName: ["이름", "Name"], + HHow: ["무엇으로", "From"], + HValue: ["값", "Value"], + HUnit: ["단위", "Unit"], + HSource: ["출처", "Source"], + Busy: ["계산 중", "Calculating"], + Stopped: ["계산하지 못했어요", "Could not calculate"], + ReadOnly: [ + "표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요", + "Standard is read-only — use “Copy and edit” first", + ], + Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"], + Typed: ["숫자 칸", "Number"], + Choice: ["고르기", "Choice"], + Range: ["범위", "Range"], + FromInput: ["입력값", "Input"], + FromCalc: ["계산", "Calc"], + FromLookup: ["표 조회", "Lookup"], + FromFixed: ["고정값", "Fixed"], + FromLogic: ["다른 일위대가 결과", "Other unit-price result"], + FromPeople: ["사람 품값 합", "People cost total"], + FromNone: ["무관", "Any"], + Own: ["직접 쓴 계산", "Written by hand"], + Cond: ["조건", "Condition"], + Table: ["표", "Table"], + Number: ["숫자", "Number"], + Calc: ["계산", "Calculation"], + Sum: ["항목 합계", "Item total"], + UseName: ["계산 이름", "Calc name"], + UseQty: ["항목 수량", "Item amount"], + UseRate: ["항목 비율", "Item rate"], + UseResult: ["결과값", "Result"], + Won: ["원", "KRW"], + Unnamed: ["수량 계산", "Amount calc"], + Price: ["단가", "Unit price"], + Result: ["결과", "Result"], + KValue: ["값", "Value"], + KMul: ["곱하기 · 나누기", "Multiply / divide"], + KRaise: ["할증", "Surcharge"], + KAdd: ["더하기", "Add"], + KCond: ["조건 목록", "Conditions"], + KMinMax: ["최소 · 최대", "Min / max"], + KRound: ["올림 · 버림", "Round"], + KSteps: ["여러 단계", "Several steps"], + Person: ["사람", "Person"], + Material: ["재료", "Material"], + Machine: ["기계", "Machine"], + Logic: ["다른 일위대가", "Other unit price"], + Percent: ["덧붙임", "Add-on %"], + Locked: ["그 밖 줄", "Other line"], + Base: ["바탕", "Base"], + Rate: ["율(%)", "Rate (%)"], + First: ["처음", "First"], + Times: ["곱하기", "times"], + Divide: ["나누기", "divided by"], + Plus: ["더하기", "plus"], + Minus: ["빼기", "minus"], +} as const; + +type TextKey = keyof typeof TEXT; +const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex]; + +type Part = Exclude; +type Ref = LbRef | LbExpr; +type Cell = string | HTMLElement; + +interface GridRow { + id: string; + step: string; + sub: boolean; + name: Cell; + how: Cell; + value: Cell; + unit: string; + source: string; + /** 이 줄이 쓰는 줄 번호 */ + uses: string[]; +} + +const KIND_KEY: Record = { + 값: "KValue", + 곱나눔: "KMul", + 할증: "KRaise", + 더하기: "KAdd", + 조건목록: "KCond", + 최소최대: "KMinMax", + 올림: "KRound", + 단계: "KSteps", +}; +const CARD_KEY: Record = { + person: "Person", + material: "Material", + machine: "Machine", + logic: "Logic", + percent: "Percent", + locked: "Locked", +}; +const USE_KEY: Record = { + 중간: "UseName", + 수량: "UseQty", + 비율: "UseRate", + 결과: "UseResult", +}; + +const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const show = (v: unknown): string => + typeof v === "number" + ? num(v) + : v === undefined || v === null || typeof v === "object" + ? "" + : String(v); + +/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */ +const picked = new WeakMap(); + +/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */ +function mentions(text: string, name: string): boolean { + if (!name) return false; + if (!/^\w+$/.test(name)) return text.includes(name); + return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text); +} + +export interface GridOptions { + /** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */ + trial?: Record; +} + +/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */ +export function renderGrid( + host: HTMLElement, + lb: LbLogic, + ans: CalcAnswer | null, + on: { change(lb: LbLogic): void }, + opts: GridOptions = {}, +): void { + const readOnly = lb.doc.isBase; + const okAns = ans && ans.ok ? ans : null; + const middle = okAns?.middle ?? {}; + const lines = okAns?.lines ?? []; + const msg = el("p", { className: "m01-lbgrid__msg" }); + const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name); + const apply = (make: () => LbLogic): void => { + if (readOnly) return; + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = tg("Cant"); + } + }; + + /* ---- 값 · 줄 번호 ---- */ + const rowOf = (r: Ref): string[] => { + if (r.k === "input") return [`in:${r.name}`]; + if (r.k === "lookup") return [`lk:${r.id}`]; + if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : []; + if (r.k === "expr") return textRows(r.text); + return []; + }; + const textRows = (text: string, self = -1): string[] => [ + ...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`), + ...lb.calcs.flatMap((c, i) => + i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [], + ), + ]; + const lookupsAt = (part: Part | "요소", index: number): string[] => + lb.lookups + .filter((l) => l.at.some((a) => a.part === part && a.index === index)) + .map((l) => `lk:${l.id}`); + const lineOf = (card: EasyCard) => + lines.find((l) => l.이름 === card.name && l.단위 === card.unit); + const lookupValue = (id: string): unknown => { + const solo = lb.calcs.find( + (c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id, + ); + if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name]; + const at = lb.lookups + .find((l) => l.id === id) + ?.at.find((a) => a.part === "수량" && a.start === 0); + const card = lb.doc.cards.find( + (c) => c.source?.part === "호표" && c.source.index === at?.index, + ); + const line = card && lineOf(card); + return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : ""; + }; + const valueOf = (r: Ref): unknown => { + if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name]; + if (r.k === "fixed") return r.value; + if (r.k === "calc") return middle[r.name]; + if (r.k === "lookup") return lookupValue(r.id); + if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name]; + return ""; + }; + const unitOf = (r: Ref): string => + r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : ""; + + /* ---- 칸 만들기 ---- */ + const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => { + const box = el("input", { + className: "m01-lbgrid__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") }, + }); + box.value = show(value); + box.readOnly = readOnly; + box.addEventListener("click", (e) => e.stopPropagation()); + box.addEventListener("change", () => { + const n = Number(box.value.trim()); + if (box.value.trim() === "" || Number.isNaN(n)) { + msg.textContent = tg("Bad"); + box.value = show(value); + return; + } + apply(() => make(n)); + }); + return box; + }; + + const refLabel = (r: Ref): string => { + if (r.k === "input") return `${tg("FromInput")} · ${r.name}`; + if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`; + if (r.k === "lookup") { + const l = lb.lookups.find((x) => x.id === r.id); + return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`; + } + if (r.k === "fixed") return tg("FromFixed"); + if (r.k === "logic") return tg("FromLogic"); + if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name; + return tg("Own"); + }; + const refKey = (r: Ref): string => + r.k === "input" + ? `input:${r.name}` + : r.k === "calc" + ? `calc:${r.name}` + : r.k === "lookup" + ? `lookup:${r.id}` + : r.k === "fixed" + ? "fixed" + : r.k === "logic" + ? `logic:${r.key}` + : r.k === "cost" + ? `cost:${r.name}` + : "expr"; + + /** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */ + const refSelect = ( + cur: Ref, + pool: Ref[], + none: boolean, + pick: (r: Ref | null) => LbLogic, + ): HTMLElement => { + const all = [...pool]; + if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur); + const options = [ + ...(none ? [{ value: "none", text: tg("FromNone") }] : []), + ...all.map((r) => ({ value: refKey(r), text: refLabel(r) })), + ]; + const field = createSelectField({ + options, + value: refKey(cur), + compact: true, + ariaLabel: refLabel(cur), + disabled: readOnly || cur.k === "expr", + onChange: (v) => { + if (v === refKey(cur)) return; + apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur))); + }, + }); + field.root.addEventListener("click", (e) => e.stopPropagation()); + return field.root; + }; + const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 })); + const calcRefs = (before: number): Ref[] => + lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : [])); + const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id })); + + /* ---- 줄 모으기 ---- */ + const rows: GridRow[] = []; + const add = (r: Partial & { id: string; name: Cell }): GridRow => { + const row: GridRow = { + step: "", + sub: false, + how: "", + value: "", + unit: "", + source: "", + uses: [], + ...r, + }; + rows.push(row); + return row; + }; + + lb.inputs.forEach((inp, i) => { + add({ + id: `in:${inp.이름}`, + step: i === 0 ? tg("Step1") : "", + name: inp.이름, + how: inp.고르기?.length ? tg("Choice") : tg("Typed"), + value: show(valueOf({ k: "input", name: inp.이름 })), + unit: inp.단위 ?? "", + source: inp.고르기?.length + ? inp.고르기.join(" · ") + : inp.범위 + ? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}` + : "", + }); + }); + + lb.lookups.forEach((lk, i) => { + const head = add({ + id: `lk:${lk.id}`, + step: i === 0 ? tg("Step2") : "", + name: lk.col, + how: tg("FromLookup"), + value: show(lookupValue(lk.id)), + source: `${tg("Table")} ${lk.table}`, + }); + lk.conds.forEach((cd) => { + const from = cd.from; + const fixedNum = from.k === "fixed" && typeof from.value === "number"; + const subRow = add({ + id: `lc:${lk.id}:${cd.col}`, + sub: true, + name: `${tg("Cond")} · ${cd.col}`, + how: refSelect( + from, + [ + ...inputRefs, + ...calcRefs(lb.calcs.length).filter((r) => { + const c = lb.calcs.find((x) => x.name === (r as { name: string }).name); + return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기("); + }), + ...lookupRefs.filter((r) => r.k === "lookup" && r.id !== lk.id), + ], + true, + (r) => setCond(lb, lk.id, cd.col, r), + ), + value: + fixedNum && from.k === "fixed" + ? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n })) + : show(valueOf(from)), + unit: unitOf(from), + uses: rowOf(from), + }); + head.uses.push(...subRow.uses); + }); + }); + + lb.calcs.forEach((c, i) => { + const first = i === 0; + const own = lookupsAt(c.at.part, c.at.index); + const head = add({ + id: `ca:${i}`, + step: first ? tg("Step3") : "", + name: c.name ?? `${tg("Unnamed")} ${i + 1}`, + how: tg(KIND_KEY[c.kind]), + value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""), + source: tg(USE_KEY[c.at.part]), + uses: [...textRows(c.text, i), ...own], + }); + if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return; + c.parts.forEach((p, pi) => { + const label = + c.kind === "할증" + ? tg(pi === 0 ? "Base" : "Rate") + : c.kind === "값" + ? tg("KValue") + : pi === 0 + ? tg("First") + : tg( + ({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[ + c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*") + ], + ); + const fixedNum = p.k === "fixed" && typeof p.value === "number"; + const setPart = (r: Ref | null): LbLogic => { + const parts = c.parts.slice(); + if (r) parts[pi] = r; + return setCalc(lb, c, { parts }); + }; + const sub = add({ + id: `cp:${i}:${pi}`, + sub: true, + name: label, + how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart), + value: + fixedNum && p.k === "fixed" + ? numberCell(p.value, (n) => setPart({ k: "fixed", value: n })) + : show(valueOf(p)), + unit: unitOf(p), + uses: rowOf(p), + }); + head.uses.push(...sub.uses); + }); + }); + + lb.doc.cards.forEach((card, i) => { + const sp = card.source; + const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"]; + const viaLookup = sp + ? parts + .flatMap((p) => lookupsAt(p, sp.index)) + .concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : []) + : []; + const viaCalc = sp + ? lb.calcs.flatMap((c, ci) => + parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [], + ) + : []; + const line = lineOf(card); + const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc"); + add({ + id: `it:${card.id}`, + step: i === 0 ? tg("Step4") : "", + name: card.spec ? `${card.name} ${card.spec}` : card.name, + how: tg(CARD_KEY[card.kind]), + value: + card.qty !== null + ? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) })) + : show(line?.수량), + unit: card.unit, + source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`, + uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])], + }); + }); + + const res = lb.result; + add({ + id: "res", + step: tg("Step5"), + name: tg(res.kind === "금액" ? "Price" : "Result"), + how: res.kind, + value: + res.kind === "금액" + ? okAns + ? formatMoney(okAns.sums?.["계"] ?? 0) + : "" + : show(okAns?.result), + unit: res.unit, + source: res.kind === "금액" ? tg("Sum") : tg("Calc"), + uses: + res.kind === "금액" + ? lb.doc.cards.map((c) => `it:${c.id}`) + : [ + ...textRows(res.calc.text), + ...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])), + ], + }); + + /* ---- 그리기 ---- */ + const tbody = el("tbody"); + const paint = (): void => { + const sel = picked.get(host); + const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []); + tbody.querySelectorAll("tr").forEach((tr) => { + const id = tr.dataset.row ?? ""; + tr.classList.toggle("m01-lbgrid__row--on", id === sel); + tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id)); + }); + }; + const td = (c: Cell, className = ""): HTMLElement => + el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) }); + rows.forEach((r) => { + const tr = el("tr", { + className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`, + attrs: { "data-row": r.id }, + children: [ + td(r.step, "m01-lbgrid__step"), + td(r.name, "m01-lbgrid__name"), + td(r.how), + td(r.value, "m01-lbgrid__num"), + td(r.unit), + td(r.source, "m01-lbgrid__src"), + ], + }); + tr.addEventListener("click", () => { + if (picked.get(host) === r.id) picked.delete(host); + else picked.set(host, r.id); + paint(); + }); + tbody.append(tr); + }); + const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"]; + const status = + ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy"); + host.replaceChildren( + ...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []), + ...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []), + el("div", { + className: "m01-lbgrid__scroll", + children: [ + el("table", { + className: "m01-lbgrid__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })], + }), + tbody, + ], + }), + ], + }), + msg, + el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }), + ); + paint(); +} From d92cbf588276511637b01fddbb6c0221b2556e77 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:48:12 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat(M01-=EB=A1=9C=EC=A7=81=ED=8C=90):=20?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=20=EB=AC=B8=EC=9E=A5=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20Steps=20=EC=B6=94=EA=B0=80=20-=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=ED=91=9C=20=EC=A1=B0=ED=9A=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EA=B2=B0=EA=B3=BC=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M01_MasterData/LogicBuilder/M01_LB_Steps.css | 201 ++++++++++ M01_MasterData/LogicBuilder/M01_LB_Steps.ts | 370 ++++++++++++++++++ .../LogicBuilder/M01_LB_Steps_Calc.ts | 201 ++++++++++ .../LogicBuilder/M01_LB_Steps_Cell.ts | 284 ++++++++++++++ 4 files changed, 1056 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps.ts create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.css b/M01_MasterData/LogicBuilder/M01_LB_Steps.css new file mode 100644 index 000000000..6ae219619 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.css @@ -0,0 +1,201 @@ +.m01-lbsteps { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-lbsteps-banner { + margin: 0; + padding: var(--spacing-8) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-lbsteps-banner.is-warn { + border-left: 4px solid var(--color-warning); +} + +.m01-lbsteps-banner p { + margin: 0; +} + +.m01-lbsteps-why { + color: var(--color-text-secondary); + font-weight: var(--font-weight-regular); +} + +.m01-lbsteps-sec { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + padding: var(--spacing-8) var(--spacing-16) var(--spacing-16); +} + +.m01-lbsteps-title { + margin: 0 0 var(--spacing-8); + font-size: var(--text-subheading); + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-list, +.m01-lbsteps-branches { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin: 0; + padding: 0; + list-style: none; +} + +.m01-lbsteps-row { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lbsteps-row:first-child { + padding-top: 0; + border-top: 0; +} + +.m01-lbsteps-sentence { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-8); + min-width: 0; +} + +.m01-lbsteps-ref, +.m01-lbsteps-cond, +.m01-lbsteps-chips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.m01-lbsteps-shapes, +.m01-lbsteps-shape { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-left: var(--spacing-16); + border-left: 2px solid var(--color-border); +} + +.m01-lbsteps-branch { + padding-left: var(--spacing-8); +} + +.m01-lbsteps-order { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.6em; + height: 1.6em; + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-name, +.m01-lbsteps-tag { + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-chip { + display: inline-block; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-lbsteps-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-lbsteps-value, +.m01-lbsteps-sum { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.m01-lbsteps-amount, +.m01-lbsteps-total { + margin-left: auto; + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.m01-lbsteps-amount.is-wait { + opacity: 0.5; + font-weight: var(--font-weight-regular); +} + +.m01-lbsteps-pick { + min-height: 44px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.m01-lbsteps-pick:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-lbsteps .ui-input, +.m01-lbsteps .ui-select, +.m01-lbsteps .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-lbsteps-num { + flex: none; + width: 6em; +} + +.m01-lbsteps-text { + flex: none; + width: 9em; +} + +.m01-lbsteps-select { + flex: none; + width: auto; + max-width: 100%; +} + +.m01-lbsteps .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m01-lbsteps-add { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +@media (max-width: 640px) { + .m01-lbsteps-amount, + .m01-lbsteps-total { + margin-left: 0; + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts new file mode 100644 index 000000000..3c7d266db --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * M01_LB_Steps.ts + * 로직 만들기 판 가운데 문장 보기 — ① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값 + * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · `로직판_A_설계초안.md` 7-1 그대로 · 모두 고르기 · 숫자 칸 · 바뀐 lb 는 on.change 로만 알림 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { + addCard, + addPercent, + removeCard, + updateCard, + withInputs, + type CostKind, + type EasyCard, + type PickResult, +} from "../EasyCalc/M01_EasyCalc_Model"; +import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; +import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Blocks"; +import { calcBody, calcView } from "./M01_LB_Steps_Calc"; +import { + chip, + lookupValue, + note, + numBox, + parseNum, + refText, + selectBox, + sentence, + span, + textBox, + tx, + valueNote, + type Ctx, + type TEXT, +} from "./M01_LB_Steps_Cell"; +import "./M01_LB_Steps.css"; + +type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; + +let picking = false; + +/* ------------------------------ ① 입력값 ------------------------------ */ + +function inputView(ctx: Ctx, input: LogicInput, at: number): HTMLElement { + const put = (patch: Partial): void => + ctx.commit(() => { + const inputs = ctx.lb.inputs.map((x, i) => (i === at ? { ...x, ...patch } : x)); + return { ...ctx.lb, inputs, doc: { ...ctx.lb.doc, inputs } }; + }); + const parts: (HTMLElement | string)[] = [span("m01-lbsteps-name", input.이름)]; + if (input.고르기) { + parts.push( + note(tx("PickList")), + el("span", { + className: "m01-lbsteps-chips", + children: input.고르기.map((v) => chip(String(v))), + }), + ); + } else if (input.범위) { + const [lo, hi] = input.범위; + parts.push( + note(tx("Range")), + numBox(ctx, lo, (n) => put({ 범위: [n, hi] })), + "~", + numBox(ctx, hi, (n) => put({ 범위: [lo, n] })), + ); + } else parts.push(note(tx("Number"))); + parts.push( + note(tx("Unit")), + textBox(ctx, input.단위 ?? "", (v) => put({ 단위: v })), + ); + return el("li", { className: "m01-lbsteps-row", children: [sentence(...parts)] }); +} + +/* ------------------------------ ② 표 조회 ------------------------------ */ + +function condView(ctx: Ctx, lk: LbLookup, cond: LbLookup["conds"][number]): HTMLElement { + const from = cond.from; + const set = (next: LbRef | null): void => + ctx.commit(() => setCond(ctx.lb, lk.id, cond.col, next)); + const parts: (HTMLElement | string)[] = [chip(cond.col), tx("LkIs")]; + if (from.k === "input" || from.k === "fixed") { + const options = [ + ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), + { value: "f", text: tx("SrcFixed") }, + { value: "n", text: tx("SrcAny") }, + ]; + const current = from.k === "input" ? `i:${from.name}` : "f"; + parts.push( + selectBox( + ctx, + options, + current, + (v) => + set( + v === "n" + ? null + : v === "f" + ? { k: "fixed", value: "" } + : { k: "input", name: v.slice(2) }, + ), + cond.col, + ), + ); + if (from.k === "fixed") { + parts.push( + textBox(ctx, String(from.value), (v) => set({ k: "fixed", value: parseNum(v) ?? v })), + ); + } + } else parts.push(chip(refText(ctx, from))); + return el("span", { className: "m01-lbsteps-cond", children: parts }); +} + +function lookupView(ctx: Ctx, lk: LbLookup, n: number): HTMLElement { + const name = ctx.on.tableName?.(lk.table) ?? lk.table; + return el("li", { + className: "m01-lbsteps-row", + children: [ + sentence( + span("m01-lbsteps-name", `${tx("LkNum")}${n}`), + chip(name), + tx("LkFrom"), + ...lk.conds.map((c) => condView(ctx, lk, c)), + tx("LkRow"), + chip(lk.col), + valueNote(lookupValue(ctx, lk)), + ), + ], + }); +} + +/* ------------------------------ ④ 항목 연결 ------------------------------ */ + +const BASES: (CostKind | "사람줄합")[] = ["노무비", "재료비", "경비", "사람줄합"]; + +function lineMap(ctx: Ctx): Map { + const out = new Map(); + const ans = ctx.ans; + if (!ans || !ans.ok || !ans.lines) return out; + const cards = ctx.lb.doc.cards; + const part = (c: EasyCard): string => + c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; + const ordered = [ + ...cards.filter((c) => part(c) === "호표"), + ...cards.filter((c) => part(c) === "덧줄"), + ]; + if (ordered.length === ans.lines.length) ordered.forEach((c, i) => out.set(c.id, ans.lines![i])); + return out; +} + +const patchItem = (ctx: Ctx, id: string, patch: Partial): void => + ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) })); + +async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise { + if (picking) return; + picking = true; + try { + const pick = await openEasyPick(kind); + if (!pick) return; + ctx.commit(() => { + if (!card) return { ...ctx.lb, doc: addCard(ctx.lb.doc, pick) }; + const next = updateCard(ctx.lb.doc, card.id, { + name: pick.name, + spec: pick.spec, + unit: pick.unit, + element: pick.element, + }); + return { ...ctx.lb, doc: withInputs(next, pick.needInputs ?? []) }; + }); + } finally { + picking = false; + } +} + +function itemView(ctx: Ctx, card: EasyCard): HTMLElement { + const kind = card.kind as Kind; + const percent = kind === "percent"; + const part = card.source?.part === "호표" ? "수량" : "비율"; + const calc = card.source + ? ctx.lb.calcs.find((c) => c.at.part === part && c.at.index === card.source?.index) + : undefined; + const line = ctx.lines.get(card.id); + const qty: HTMLElement = calc + ? el("span", { + className: "m01-lbsteps-ref", + children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)], + }) + : card.qty !== null && card.qtyFormula === null + ? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n })) + : chip(tx("Hard")); + const name = + ctx.ro || kind === "locked" || percent + ? span("m01-lbsteps-name", [card.name, card.spec].filter(Boolean).join(" ")) + : (() => { + const b = el("button", { + className: "m01-lbsteps-pick", + text: `${[card.name, card.spec].filter(Boolean).join(" ")} ▾`, + attrs: { type: "button", "aria-label": tx("Repick") }, + }); + b.addEventListener("click", () => void pickItem(ctx, kind as PickResult["kind"], card)); + return b; + })(); + const head: (HTMLElement | string)[] = [span("m01-lbsteps-tag", `【${tx(`Tag_${kind}`)}】`)]; + if (percent) { + const options = BASES.map((v) => ({ value: v, text: tx(`Base_${v}`) })); + head.push( + selectBox( + ctx, + options, + card.percentBase ?? "노무비", + (v) => patchItem(ctx, card.id, { percentBase: v as EasyCard["percentBase"] }), + tx("S4"), + ), + tx("Pre_percent"), + qty, + tx("Post_percent"), + name, + ); + } else { + head.push( + name, + kind === "person" ? tx("Pre_person") : tx("Pre_pick"), + qty, + span("m01-lbsteps-unit", ` ${card.unit}`), + ); + } + head.push( + line + ? span("m01-lbsteps-amount", `= ${formatMoney(line.금액)} ${tx("Won")}`) + : span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`), + ); + if (!ctx.ro) { + head.push( + createButton({ + label: tx("Remove"), + variant: "ghost", + size: "sm", + onClick: () => ctx.commit(() => ({ ...ctx.lb, doc: removeCard(ctx.lb.doc, card.id) })), + }), + ); + } + return el("li", { className: "m01-lbsteps-row", children: [sentence(...head)] }); +} + +function addBar(ctx: Ctx): HTMLElement { + const add = (key: keyof typeof TEXT, run: () => void): HTMLButtonElement => + createButton({ label: tx(key), variant: "ghost", size: "sm", onClick: run }); + const pick = (kind: PickResult["kind"]) => () => void pickItem(ctx, kind); + return el("div", { + className: "m01-lbsteps-add", + children: [ + add("Add_person", pick("person")), + add("Add_material", pick("material")), + add("Add_machine", pick("machine")), + add("Add_logic", pick("logic")), + add("Add_percent", () => + ctx.commit(() => ({ ...ctx.lb, doc: addPercent(ctx.lb.doc, "", "노무비", null, "경비") })), + ), + ], + }); +} + +/* ------------------------------ ⑤ 결과값 ------------------------------ */ + +function resultView(ctx: Ctx): HTMLElement { + const result = ctx.lb.result; + const ans = ctx.ans; + if (result.kind === "값") { + const value = ans?.ok ? ans.result : undefined; + return el("div", { + children: [ + sentence(note(tx("ResultValue")), chip(result.unit), valueNote(value)), + calcBody(ctx, result.calc), + ], + }); + } + const sums = ans?.ok ? ans.sums : undefined; + const parts: (HTMLElement | string)[] = [note(tx("ResultMoney")), chip(result.unit)]; + if (sums) { + for (const k of ["노무비", "재료비", "경비"]) { + if (k in sums) + parts.push( + span("m01-lbsteps-sum", `${tx(`Base_${k as CostKind}`)} ${formatMoney(sums[k])}`), + ); + } + parts.push( + span("m01-lbsteps-total", `${tx("Total")} ${formatMoney(sums["계"] ?? 0)} ${tx("Won")}`), + ); + } else parts.push(span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`)); + return sentence(...parts); +} + +/* ------------------------------ 전체 ------------------------------ */ + +function section(title: string, items: HTMLElement[], extra?: HTMLElement): HTMLElement { + const body = items.length + ? el("ol", { className: "m01-lbsteps-list", children: items }) + : note(tx("None")); + return el("section", { + className: "m01-lbsteps-sec", + children: [ + el("h3", { className: "m01-lbsteps-title", text: title }), + body, + ...(extra ? [extra] : []), + ], + }); +} + +/** 가운데 문장 보기 그리기 — ans 는 칸마다 곁에 보이는 예시값 · null 이면 「계산 중」 */ +export function renderSteps( + host: HTMLElement, + lb: LbLogic, + ans: CalcAnswer | null, + on: { change(lb: LbLogic): void; tableName?(table: string): string | null }, +): void { + const ctx: Ctx = { + lb, + ans, + ro: lb.doc.isBase, + on, + lines: new Map(), + commit: (make) => { + try { + on.change(make()); + } catch { + showToast(tx("Cannot"), "error"); + } + }, + }; + ctx.lines = lineMap(ctx); + host.classList.add("m01-lbsteps"); + const top: HTMLElement[] = []; + if (ctx.ro) top.push(el("p", { className: "m01-lbsteps-banner", text: tx("BaseNote") })); + if (lb.blocked.length) { + top.push( + el("div", { + className: "m01-lbsteps-banner is-warn", + children: [ + el("p", { text: `⚠ ${tx("Blocked")}` }), + ...lb.blocked.map((b) => el("p", { className: "m01-lbsteps-why", text: b })), + ], + }), + ); + } + host.replaceChildren( + ...top, + section( + tx("S1"), + lb.inputs.map((x, i) => inputView(ctx, x, i)), + ), + section( + tx("S2"), + lb.lookups.map((l, i) => lookupView(ctx, l, i + 1)), + ), + section( + tx("S3"), + lb.calcs.map((c, i) => calcView(ctx, c, i + 1)), + ), + section( + tx("S4"), + lb.doc.cards.map((c) => itemView(ctx, c)), + ctx.ro ? undefined : addBar(ctx), + ), + section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]), + ); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts new file mode 100644 index 000000000..213b1ebcd --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * M01_LB_Steps_Calc.ts + * 로직 만들기 판 문장 보기 ③ 계산 — 값 · 곱나눔 · 할증 · 조건 목록 · 최소/최대 · 올림 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { CondOp, CondRead, CondShape, CondSlot } from "../EasyCalc/M01_EasyCalc_Cond"; +import { setCalc, type LbCalc } from "./M01_LB_Blocks"; +import { + chip, + middleOf, + note, + numBox, + refBox, + selectBox, + sentence, + span, + textBox, + tx, + valueNote, + type Ctx, + type TEXT, +} from "./M01_LB_Steps_Cell"; + +const OPS: [CondOp, keyof typeof TEXT][] = [ + ["==", "OpEq"], + ["!=", "OpNe"], + [">", "OpGt"], + [">=", "OpGe"], + ["<", "OpLt"], + ["<=", "OpLe"], +]; + +function slotBox(ctx: Ctx, slot: CondSlot, put: (s: CondSlot) => void): HTMLElement { + if (slot.kind === "num") return numBox(ctx, slot.value, (n) => put({ kind: "num", value: n })); + if (slot.kind === "text") return textBox(ctx, slot.value, (v) => put({ kind: "text", value: v })); + if (slot.kind === "input") { + const options = ctx.lb.inputs.map((i) => ({ value: i.이름, text: i.이름 })); + return selectBox(ctx, options, slot.name, (v) => put({ kind: "input", name: v }), tx("S1")); + } + return chip(tx(slot.kind === "table" ? "SlotTable" : "SlotCalc")); +} + +function shapeView(ctx: Ctx, shape: CondShape, put: (s: CondShape) => void): HTMLElement { + if (shape.type === "pick") { + const items: (HTMLElement | string)[] = []; + shape.items.forEach((s, i) => { + if (i) items.push(tx("And")); + items.push( + slotBox(ctx, s, (v) => + put({ ...shape, items: shape.items.map((x, j) => (j === i ? v : x)) }), + ), + ); + }); + const fn = [ + { value: "최소", text: tx("Small") }, + { value: "최대", text: tx("Big") }, + ]; + return sentence( + ...items, + selectBox(ctx, fn, shape.fn, (v) => put({ ...shape, fn: v as "최소" | "최대" }), tx("S3")), + ); + } + if (shape.type === "round") { + const fns = [ + { value: "올림", text: tx("Up") }, + { value: "버림", text: tx("Down") }, + { value: "반올림", text: tx("Half") }, + ]; + return sentence( + slotBox(ctx, shape.value, (v) => put({ ...shape, value: v })), + tx("RoundA"), + numBox(ctx, shape.places, (n) => put({ ...shape, places: n, hadPlaces: true })), + tx("RoundB"), + selectBox( + ctx, + fns, + shape.fn, + (v) => put({ ...shape, fn: v as "올림" | "버림" | "반올림" }), + tx("S3"), + ), + ); + } + const ops = OPS.map(([value, key]) => ({ value, text: tx(key) })); + const rows = shape.branches.map((b, i) => { + const edit = (patch: Partial): void => + put({ ...shape, branches: shape.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); + return el("li", { + className: "m01-lbsteps-branch", + children: [ + sentence( + span("m01-lbsteps-order", String(i + 1)), + slotBox(ctx, b.test.left, (v) => edit({ test: { ...b.test, left: v } })), + tx("Subject"), + slotBox(ctx, b.test.right, (v) => edit({ test: { ...b.test, right: v } })), + selectBox( + ctx, + ops, + b.test.op, + (v) => edit({ test: { ...b.test, op: v as CondOp } }), + tx("S3"), + ), + "→", + slotBox(ctx, b.value, (v) => edit({ value: v })), + ), + ], + }); + }); + const last = el("li", { + className: "m01-lbsteps-branch", + children: [ + sentence( + tx("Otherwise"), + slotBox(ctx, shape.otherwise, (v) => put({ ...shape, otherwise: v })), + ), + ], + }); + return el("div", { + className: "m01-lbsteps-shape", + children: [ + note(tx("Cond1")), + el("ol", { className: "m01-lbsteps-branches", children: [...rows, last] }), + ], + }); +} + +function placeText(ctx: Ctx, calc: LbCalc): string { + if (calc.name !== null) return calc.name; + const { part, index } = calc.at; + if (part === "결과") return tx("PlaceResult"); + const card = ctx.lb.doc.cards.find( + (c) => c.source?.index === index && c.source.part === (part === "수량" ? "호표" : "덧줄"), + ); + return `${card?.name ?? ""} ${tx(part === "수량" ? "PlaceQty" : "PlaceRate")}`.trim(); +} + +export function calcBody(ctx: Ctx, calc: LbCalc): HTMLElement { + const put = (patch: Partial): void => ctx.commit(() => setCalc(ctx.lb, calc, patch)); + const refAt = (i: number): HTMLElement => + refBox(ctx, calc.parts[i], calc, (r) => + put({ parts: calc.parts.map((p, j) => (j === i ? r : p)) }), + ); + if (calc.cond && calc.cond.blocks.length) { + const read: CondRead = calc.cond; + return el("div", { + className: "m01-lbsteps-shapes", + children: read.blocks.map((b, bi) => + shapeView(ctx, b.shape, (shape) => + put({ + cond: { ...read, blocks: read.blocks.map((x, j) => (j === bi ? { ...x, shape } : x)) }, + }), + ), + ), + }); + } + if (calc.kind === "값" && calc.parts.length) return sentence(refAt(0)); + if (calc.kind === "할증" && calc.parts.length >= 2) { + return sentence(refAt(0), tx("Mark1"), refAt(1), tx("Mark2")); + } + if ((calc.kind === "곱나눔" || calc.kind === "더하기") && calc.parts.length) { + const ops = calc.kind === "곱나눔" ? ["*", "/"] : ["+", "-"]; + const glyph = (o: string): string => + tx(o === "*" ? "Mul" : o === "/" ? "Div" : o === "+" ? "Plus" : "Minus"); + const out: (HTMLElement | string)[] = [refAt(0)]; + calc.parts.slice(1).forEach((_, i) => { + const op = calc.ops?.[i] ?? ops[0]; + const choices = ops.map((o) => ({ value: o, text: glyph(o) })); + out.push( + selectBox( + ctx, + choices, + op, + (v) => + put({ + ops: calc.parts + .slice(1) + .map((_, j) => (j === i ? v : (calc.ops?.[j] ?? ops[0])) as "*" | "/" | "+" | "-"), + }), + tx("S3"), + ), + refAt(i + 1), + ); + }); + return sentence(...out); + } + return sentence(note(tx("Steps"))); +} + +export function calcView(ctx: Ctx, calc: LbCalc, n: number): HTMLElement { + return el("li", { + className: "m01-lbsteps-row", + children: [ + sentence( + span("m01-lbsteps-name", `${tx("Calc")}${n}`), + chip(placeText(ctx, calc)), + valueNote(middleOf(ctx, calc.name)), + ), + calcBody(ctx, calc), + ], + }); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts new file mode 100644 index 000000000..e3b1ae55d --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * M01_LB_Steps_Cell.ts + * 로직 만들기 판 문장 보기 공용 — 글 [한, 영] 짝 · 작은 칸(숫자 · 글 · 고르기) · 값이 오는 곳 고르기 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbCalc, LbExpr, LbLogic, LbLookup, LbRef } from "./M01_LB_Blocks"; + +export const TEXT = { + Blocked: ["이 화면에서 읽지 못하는 줄이 있음", "Some lines cannot be read on this screen"], + Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], + BaseNote: ["표준 로직은 읽기만 — 복사해서 고침", "Standard logic is read-only — copy to edit"], + S1: ["① 입력값", "① Input values"], + S2: ["② 표 조회", "② Table lookup"], + S3: ["③ 계산", "③ Calculation"], + S4: ["④ 항목 연결", "④ Items"], + S5: ["⑤ 결과값", "⑤ Result"], + None: ["없음", "none"], + PickList: ["선택 목록", "choose from"], + Number: ["숫자", "number"], + Range: ["범위", "range"], + Unit: ["단위", "unit"], + Eg: ["예: 3", "e.g. 3"], + NotNumber: ["숫자만 넣음", "Numbers only"], + LkFrom: [" 에서 ", " : "], + LkIs: [" 이 ", " is "], + LkRow: [" 인 줄의 ", " in the row, take "], + LkNum: ["표 조회 ", "Lookup "], + SrcInput: ["입력값 · ", "input · "], + SrcCalc: ["계산 · ", "calc · "], + SrcFixed: ["고정값", "fixed value"], + SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"], + Other: ["다른 일위대가 결과", "Other unit price result"], + Hard: ["이 화면에서 못 고치는 값", "Value not editable here"], + Mul: ["×", "×"], + Div: ["÷", "÷"], + Plus: ["+", "+"], + Minus: ["−", "−"], + Mark1: ["에 ", ": add "], + Mark2: [" % 를 더 얹음", " % on top"], + Steps: ["여러 단계로 된 계산 — 읽기만", "Multi-step calculation — read only"], + Cond1: ["위에서 처음 맞는 줄의 값을 씀", "The first matching line from the top is used"], + Subject: ["가", "is"], + Otherwise: ["그 외 →", "Otherwise →"], + Big: ["가운데 큰 값", "the larger"], + Small: ["가운데 작은 값", "the smaller"], + And: ["와", "and"], + RoundA: ["을 소수 ", "round to "], + RoundB: [" 자리까지 ", " decimals, "], + 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"], + SlotTable: ["표에서 찾은 값", "Value from table"], + SlotCalc: ["계산한 값", "Calculated value"], + PlaceQty: ["수량", "amount"], + PlaceRate: ["비율", "rate"], + PlaceResult: ["결과값", "result"], + Calc: ["계산 ", "Calc "], + Tag_person: ["노무", "Labor"], + Tag_material: ["재료", "Material"], + Tag_machine: ["장비", "Equipment"], + Tag_logic: ["일위대가", "Unit price"], + Tag_percent: ["비율", "Rate"], + Tag_locked: ["기타", "Other"], + Pre_person: [" 이 ", " works "], + Pre_pick: [" 를 ", " uses "], + Pre_percent: [" 의 ", " of "], + Post_percent: [" % ", " % "], + Repick: ["다른 것으로 다시 고르기", "Choose another"], + Remove: ["지움", "Remove"], + Pending: ["계산 중", "calculating"], + Won: ["원", "KRW"], + Add_person: ["+ 노무", "+ Labor"], + Add_material: ["+ 재료", "+ Material"], + Add_machine: ["+ 장비", "+ Equipment"], + Add_logic: ["+ 일위대가", "+ Unit price"], + Add_percent: ["+ 비율", "+ Rate"], + Base_노무비: ["노무비", "labor cost"], + Base_재료비: ["재료비", "material cost"], + Base_경비: ["경비", "expenses"], + Base_사람줄합: ["노무 줄의 합", "sum of labor lines"], + ResultMoney: ["결과 = 금액", "Result = amount"], + ResultValue: [ + "결과 = 값 — 다른 일위대가가 불러 쓸 수 있음", + "Result = value — other unit prices can use it", + ], + Total: ["합계", "Total"], +} as const; + +export const tx = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +export type Ref = LbRef | LbExpr; + +export interface Ctx { + lb: LbLogic; + ans: CalcAnswer | null; + ro: boolean; + on: { change(lb: LbLogic): void; tableName?(table: string): string | null }; + lines: Map; + /** 바꾸기 — 못 하면 쉬운 말 한 줄 */ + commit(make: () => LbLogic): void; +} + +/* ------------------------------ 작은 칸 ------------------------------ */ + +export const span = (cls: string, text: string): HTMLSpanElement => + el("span", { className: cls, text }); +export const chip = (text: string, title?: string): HTMLSpanElement => { + const c = span("m01-lbsteps-chip", text); + if (title) c.title = title; + return c; +}; +export const note = (text: string): HTMLSpanElement => span("m01-lbsteps-note", text); +export const sentence = (...parts: (HTMLElement | string)[]): HTMLElement => + el("div", { className: "m01-lbsteps-sentence", children: parts }); + +export const parseNum = (text: string): number | null => { + const t = text.replace(/[,\s]/g, ""); + return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : null; +}; + +export function numBox(ctx: Ctx, value: number | null, commit: (n: number) => void): HTMLElement { + if (ctx.ro) return chip(value === null ? "" : String(value)); + const input = el("input", { + className: "ui-input m01-lbsteps-num", + attrs: { type: "text", inputmode: "decimal", placeholder: tx("Eg") }, + }); + input.value = value === null ? "" : String(value); + input.addEventListener("change", () => { + const n = parseNum(input.value); + input.classList.toggle("is-bad", n === null); + input.title = n === null ? tx("NotNumber") : ""; + if (n !== null && n !== value) commit(n); + }); + return input; +} + +export function textBox(ctx: Ctx, value: string, commit: (v: string) => void): HTMLElement { + if (ctx.ro) return chip(value); + const input = el("input", { className: "ui-input m01-lbsteps-text", attrs: { type: "text" } }); + input.value = value; + input.addEventListener("change", () => input.value !== value && commit(input.value.trim())); + return input; +} + +export function selectBox( + ctx: Ctx, + options: { value: string; text: string }[], + current: string, + commit: (v: string) => void, + aria: string, +): HTMLElement { + const hit = options.find((o) => o.value === current); + if (ctx.ro) return chip(hit?.text ?? ""); + const select = el("select", { + className: "ui-select m01-lbsteps-select", + attrs: { "aria-label": aria }, + }); + for (const o of options) select.append(el("option", { attrs: { value: o.value }, text: o.text })); + select.value = hit ? current : (options[0]?.value ?? ""); + select.addEventListener("change", () => commit(select.value)); + return select; +} + +export const show = (v: unknown): string => (v === undefined || v === null ? "" : formatNumber(v)); +export const valueNote = (v: unknown): HTMLElement | string => + v === undefined || v === null ? "" : span("m01-lbsteps-value", `= ${show(v)}`); + +/* ------------------------------ 값 찾기 ------------------------------ */ + +export const middleOf = (ctx: Ctx, name: string | null): unknown => + name !== null && ctx.ans?.ok ? ctx.ans.middle[name] : undefined; + +/** 표 조회 값 — 그 값 하나만 이름 붙은 계산이 되어 있을 때만 앎 */ +export function lookupValue(ctx: Ctx, lk: LbLookup): unknown { + const calc = ctx.lb.calcs.find( + (c) => + c.kind === "값" && + c.name !== null && + c.parts.length === 1 && + isRef(c.parts[0], "lookup", lk.id), + ); + return middleOf(ctx, calc?.name ?? null); +} + +export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && r.id === id; + +export const lookupNo = (ctx: Ctx, id: string): number => + ctx.lb.lookups.findIndex((l) => l.id === id) + 1; + +export function refText(ctx: Ctx, r: Ref): string { + switch (r.k) { + case "input": + return r.name; + case "fixed": + return String(r.value); + case "calc": + return r.name; + case "lookup": { + const lk = ctx.lb.lookups.find((l) => l.id === r.id); + return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`; + } + case "logic": + return `${tx("Other")} ${r.key}${r.field ? ` · ${r.field}` : ""}`; + case "cost": + return tx(`Base_${r.name}`); + default: + return tx("Hard"); + } +} + +export function refValue(ctx: Ctx, r: Ref): unknown { + if (r.k === "calc") return middleOf(ctx, r.name); + if (r.k === "fixed") return r.value; + if (r.k === "lookup") { + const lk = ctx.lb.lookups.find((l) => l.id === r.id); + return lk ? lookupValue(ctx, lk) : undefined; + } + return undefined; +} + +/** 값이 오는 곳 고르기 — 입력값 · 표 조회 · 다른 계산 · 고정값 */ +export function refBox( + ctx: Ctx, + ref: Ref, + self: LbCalc | null, + pick: (r: LbRef) => void, +): HTMLElement { + const label = refText(ctx, ref); + const editable = ref.k === "input" || ref.k === "calc" || ref.k === "lookup" || ref.k === "fixed"; + if (ctx.ro || !editable) { + return el("span", { + className: "m01-lbsteps-ref", + children: [chip(label), valueNote(refValue(ctx, ref))], + }); + } + const options = [ + ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), + ...ctx.lb.lookups.map((l) => ({ + value: `l:${l.id}`, + text: refText(ctx, { k: "lookup", id: l.id }), + })), + ...ctx.lb.calcs + .filter((c) => c.name !== null && c !== self) + .map((c) => ({ value: `c:${c.name}`, text: `${tx("SrcCalc")}${c.name}` })), + { value: "f", text: tx("SrcFixed") }, + ]; + const current = + ref.k === "input" + ? `i:${ref.name}` + : ref.k === "calc" + ? `c:${ref.name}` + : ref.k === "lookup" + ? `l:${ref.id}` + : "f"; + const toRef = (v: string): LbRef => + v === "f" + ? { k: "fixed", value: 0 } + : v.startsWith("i:") + ? { k: "input", name: v.slice(2) } + : v.startsWith("l:") + ? { k: "lookup", id: v.slice(2) } + : { k: "calc", name: v.slice(2) }; + const parts: (HTMLElement | string)[] = [ + selectBox(ctx, options, current, (v) => pick(toRef(v)), tx("S3")), + ]; + if (ref.k === "fixed") { + parts.push( + numBox(ctx, typeof ref.value === "number" ? ref.value : null, (n) => + pick({ k: "fixed", value: n }), + ), + ); + } else parts.push(valueNote(refValue(ctx, ref))); + return el("span", { className: "m01-lbsteps-ref", children: parts }); +} From 59af3f94efcfa16770dcac3d58a85e784c793d50 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:49:32 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat(m01-lb):=20Entry=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=EA=B0=92=20=EA=B3=84=EC=82=B0=EC=9D=84=20lbCalcLater?= =?UTF-8?q?=20=EB=A1=9C=20=C2=B7=20=EC=9E=84=EC=8B=9C=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20=EA=B8=B8=20=EA=B1=B7=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 --- M01_MasterData/LogicBuilder/M01_LB_Entry.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts index 0ceb0e6cd..e68ed3189 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -20,7 +20,6 @@ import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logi import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; import { - calcLater, copyEasy, listEasy, openEasy, @@ -117,7 +116,7 @@ interface Paper { tables: LbTables; } -/* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ +/** 블록 읽기 — 못 읽는 꼴이면 null(세 칸은 원래 줄로 보임) */ const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); @@ -125,16 +124,6 @@ const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): return null; } }; -const calcWith = ( - p: Paper, - onDone: (ans: CalcAnswer | null, plain: string | null) => void, -): void => { - try { - lbCalcLater(p.row, p.trial, p.tables, onDone); - } catch { - calcLater(p.row, p.trial, onDone); - } -}; /** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createLogicBuilderButton(): HTMLButtonElement { @@ -306,7 +295,7 @@ function openLogicBuilder(): void { const calc = (): void => { drawResult(true); - calcWith(p, (next, plain) => { + lbCalcLater(p.row, p.trial, p.tables, (next, plain) => { ans = next; note.textContent = plain ?? ""; drawResult(false); From b418f6276240d69c23ec39c70c8f1e47ce15d3c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:49:55 +0900 Subject: [PATCH 07/20] =?UTF-8?q?fix(M01-=EB=A1=9C=EC=A7=81=ED=8C=90):=20S?= =?UTF-8?q?teps=20=EC=83=88=20=EB=B9=84=EC=9C=A8=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=88=AB=EC=9E=90=20=EC=B9=B8=20=C2=B7=20=EA=B3=B1=EB=82=98?= =?UTF-8?q?=EB=88=94=20=EC=97=B0=EC=82=B0=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M01_MasterData/LogicBuilder/M01_LB_Steps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts index 3c7d266db..9e92ed7e1 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts @@ -189,7 +189,7 @@ function itemView(ctx: Ctx, card: EasyCard): HTMLElement { className: "m01-lbsteps-ref", children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)], }) - : card.qty !== null && card.qtyFormula === null + : card.qtyFormula === null ? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n })) : chip(tx("Hard")); const name = From aedcef44463ef65fb912fd2f56f1b177161ad3fd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:50:09 +0900 Subject: [PATCH 08/20] =?UTF-8?q?fix(m01-lb):=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=91=9C=20=EC=A1=B0=ED=9A=8C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=ED=91=9C=20=EC=A1=B0=ED=9A=8C=20=EB=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/LogicBuilder/M01_LB_Grid.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts index 2e69ecd3b..1932674df 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts @@ -364,7 +364,6 @@ export function renderGrid( const c = lb.calcs.find((x) => x.name === (r as { name: string }).name); return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기("); }), - ...lookupRefs.filter((r) => r.k === "lookup" && r.id !== lk.id), ], true, (r) => setCond(lb, lk.id, cd.col, r), From d6ae40b117bd002755d6584bf794f450a4b6a4e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:52:22 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat(m01-lb):=20lbCalcNow=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8B=A4=EB=A6=BC=20=EC=97=86=EC=9D=B4=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EC=85=88(=EA=B8=B0=EB=8C=80=EA=B0=92=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=ED=91=9C=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 /api/m01/lb/calc 길 · 최신만 안 씀(lbCalcLater 와 안 걸림) - 서버에 못 닿으면 null · 멈춤은 답 그대로 · 둘 다 onPlain 에 쉬운 말 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/LogicBuilder/M01_LB_Api.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Api.ts b/M01_MasterData/LogicBuilder/M01_LB_Api.ts index c48f7d03c..3b63641c6 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Api.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Api.ts @@ -84,6 +84,24 @@ export function lbCalcLater( }, CALC_WAIT_MS); } +/** 기다림 없이 바로 셈(기대값 시험표처럼 여러 줄을 차례로) — `lbCalcLater` 의 최신만과 안 걸림. + * 서버에 못 닿으면 null · 멈춤(ok false)은 답 그대로 · 둘 다 `onPlain` 에 쉬운 말 */ +export async function lbCalcNow( + row: LogicRow, + inputs: Record, + tables: LbTables, + onPlain?: (plain: string) => void, +): Promise { + try { + const ans = await send("/calc", { row, inputs: inputsFor(row, inputs), tables }); + if (!ans.ok) onPlain?.(plainStop(ans.reason)); + return ans; + } catch (e: unknown) { + onPlain?.(plainError(e)); + return null; + } +} + /** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */ export async function tableUsers(key: string): Promise { const got = await send<{ users: LbTableUser[] }>( From f93ce9f6762e45f2e034dfb021cd9c9a9c9238fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:59:33 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix(M01):=20=EA=B8=B0=EB=8C=80=EA=B0=92?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=ED=91=9C=EA=B0=80=20LB=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=84=EC=82=B0(lbCalcNow)=EC=9D=84=20=EC=94=80?= =?UTF-8?q?=20=E2=80=94=20=ED=99=94=EB=A9=B4=20=EC=98=88=EC=8B=9C=EA=B0=92?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=EA=B3=BC=20=EC=95=88=20=EB=B6=80=EB=94=AA?= =?UTF-8?q?=ED=9E=98(PLAN=2035-2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멈춤 까닭은 Api 쉬운 말로 - 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록 - ORCA GF000219 시험표 세 줄: 계 79,279.668 통과 · 틀린 기대값 실패 · 해당 없음 조합 계 0 실패 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts index 075c8a9f4..95d28fc75 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Check.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -12,7 +12,7 @@ * ========================================================================== */ import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import { lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { lbCalcNow, type LbTables } from "./M01_LB_Api"; import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks"; type Cell = string | number | boolean | null | [number | null, number | null]; @@ -212,20 +212,20 @@ type CalcFn = ( tables: LbTables, ) => Promise; -/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */ -const calcOnce: CalcFn = (row, inputs, tables) => - new Promise((resolve, reject) => - lbCalcLater(row, inputs, tables, (ans, plain) => - ans ? resolve(ans) : reject(new Error(plain ?? "")), - ), - ); +/** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */ +const calcNow: CalcFn = async (row, inputs, tables) => { + let plain = ""; + const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p)); + if (!ans) throw new Error(plain); + return ans.ok ? ans : { ok: false, reason: plain || ans.reason }; +}; /** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */ export async function runExpect( lb: LbLogic, tables: LbTables, rows: ExpectRow[], - calc: CalcFn = calcOnce, + calc: CalcFn = calcNow, ): Promise { const row = writeLogic(lb); const out: ExpectResult[] = []; From 8742cd10f902df6f2171018d85619045183dce9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:04:23 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=9B=90=EB=AC=B8=20=C2=B7=20=EB=AC=B8=EC=9E=A5/?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=C2=B7=20=EA=B2=B0=EA=B3=BC(=EC=98=88?= =?UTF-8?q?=EC=8B=9C=EA=B0=92=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=ED=91=9C=20=C2=B7=20=EB=B9=88=ED=8B=88=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=20=EC=8B=9C=ED=97=98=ED=91=9C)=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5=20=C2=B7=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EC=82=AD=EC=A0=9C=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=95=88=EB=82=B4?= 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 --- M01_MasterData/LogicBuilder/M01_LB_Entry.css | 39 ++ M01_MasterData/LogicBuilder/M01_LB_Entry.ts | 491 ++++++++++++------ .../LogicBuilder/M01_LB_Entry_Result.ts | 273 ++++++++++ 3 files changed, 657 insertions(+), 146 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css index 9975c9b32..26f9e736e 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.css +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.css @@ -201,3 +201,42 @@ overflow: auto; } } + +/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */ +.m01-lb__check { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lb__check-tools { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-lb__check-out ul { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px auto auto; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row .is-ok { + color: var(--color-success, var(--color-text)); + font-weight: 600; +} + +.m01-lb__expect-row .is-bad { + color: var(--color-danger); + font-weight: 600; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts index e68ed3189..792c39700 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -1,9 +1,11 @@ /* ============================================================================= * M01_LB_Entry.ts - * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠 + * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임. - * 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음. + * 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid) + * · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본) + * → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내. + * 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -21,14 +23,24 @@ import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; import { copyEasy, + deleteEasy, + editEasy, listEasy, + newEasy, openEasy, plainError, + usesOf, type EasyListItem, } from "../EasyCalc/M01_EasyCalc_Api"; import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; -import { lbCalcLater, type LbTables } from "./M01_LB_Api"; -import { readLogic, type LbLogic } from "./M01_LB_Blocks"; +import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks"; +import { lookupHits } from "./M01_LB_Check"; +import { drawExamples, renderChecks } from "./M01_LB_Entry_Result"; +import { renderGrid } from "./M01_LB_Grid"; +import { renderSource, type LbRole } from "./M01_LB_Source"; +import { renderSteps } from "./M01_LB_Steps"; +import { openWizard } from "./M01_LB_Wizard"; import "./M01_LB_Entry.css"; const TEXT = { @@ -68,14 +80,23 @@ const TEXT = { View_Grid: ["격자", "Grid"], Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], Value: ["결과 {v} {u}", "Result {v} {u}"], - Examples: ["예시값", "Example values"], - Examples_Note: [ - "예시값은 계산을 보려는 값 — 저장 안 됨", - "Example values are only for trying — not saved", - ], - Pick: ["선택", "Choose"], Sheet: ["일위대가표", "Unit-cost sheet"], - NotYet: ["준비 중", "Coming soon"], + NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"], + Blocked: ["못 읽은 곳:", "Not readable:"], + Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"], + Role_NoInput: [ + "「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함", + "To use “{v}” as a condition, add an input with the same name", + ], + NoName: ["이름을 넣어 주세요", "Enter a name"], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함", "Not saved"], + Delete: ["삭제", "Delete"], + Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["삭제했음", "Deleted"], + Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"], + Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -105,19 +126,32 @@ const amountOf = (resultUnit: string): string => { }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */ +/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */ interface Paper { - key: string; - version: string; + key: string | null; + version: string | null; isBase: boolean; row: LogicRow; trial: Record; lb: LbLogic | null; tables: LbTables; + changed: Set; } +/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */ +interface Snap { + lb: LbLogic | null; + tables: LbTables; + changed: Set; +} +type View = "sentence" | "grid"; -/** 블록 읽기 — 못 읽는 꼴이면 null(세 칸은 원래 줄로 보임) */ -const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => { +/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */ +const blocksOf = ( + row: LogicRow, + key: string | null, + version: string | null, + isBase: boolean, +): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); } catch { @@ -132,16 +166,27 @@ export function createLogicBuilderButton(): HTMLButtonElement { function openLogicBuilder(): void { let body: HTMLElement; - let paper: Paper | null = null; - let dirty = false; + /** 세 칸 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; - let view: "sentence" | "grid" = "sentence"; + let view: View = "sentence"; const mayLeave = async (): Promise => - !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - const notYet = (): void => showToast(te("NotYet"), "info"); + /** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */ + const onKey = (ev: KeyboardEvent): void => { + 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(); + } + }; /* ---------- 시작 화면 ---------- */ @@ -166,7 +211,6 @@ function openLogicBuilder(): void { const showStart = async (): Promise => { paper = null; - dirty = false; const status = el("p", { className: "m01-lb__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-lb__list" }); const fieldHost = el("div", { className: "m01-lb__list" }); @@ -215,7 +259,7 @@ function openLogicBuilder(): void { el("div", { className: "m01-lb__start-col", children: [ - bigButton(te("Start_Wizard"), notYet), + bigButton(te("Start_Wizard"), () => void openNew()), el("h4", { text: te("Start_Field") }), fieldHost, ], @@ -240,9 +284,7 @@ function openLogicBuilder(): void { ); const field = logics.filter((x) => !x.isBase); fieldHost.replaceChildren( - ...(field.length - ? field.map(listRow) - : [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]), + ...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]), ); drawFound(); }; @@ -250,76 +292,232 @@ function openLogicBuilder(): void { /* ---------- 세 칸 화면 ---------- */ const openPaper = async (key: string): Promise => { - body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") })); + body.replaceChildren(muted(te("Loading"))); try { - const opened = await openEasy(key); - const lb = blocksOf(opened.row, key, opened.version, opened.isBase); - paper = { key, ...opened, lb, tables: {} }; - dirty = false; - showPaper(); + const o = await openEasy(key); + const lb = blocksOf(o.row, key, o.version, o.isBase); + showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial }); } catch (error) { showToast(plainError(error), "error"); void showStart(); } }; - const showPaper = (): void => { - const p = paper; - if (!p) return; + const openNew = async (): Promise => { + const lb = await openWizard(); + if (!lb) return; + const row = writeLogic(lb); + showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) }); + }; + + const showPaper = (first: Omit): void => { + const p: Paper = { ...first, tables: {}, changed: new Set() }; let ans: CalcAnswer | null = null; let busy = false; + const savedLb = p.key ? p.lb : null; + let stack: Snap[] = []; + const readOnly = p.isBase || !p.lb; + paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() }; const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = !dirty; const total = el("div", { className: "m01-lb__total" }); const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } }); const sheetHost = el("div"); const examplesHost = el("div", { className: "m01-lb__examples" }); + const checksHost = el("div"); const sourceHost = el("div", { className: "m01-lb__slot" }); const basisHost = el("div", { className: "m01-lb__slot" }); + const blockedNote = el("p", { className: "m01-lb__note" }); - const name = createInputField({ label: te("Name"), value: p.row.이름 }); - name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음 + const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row); + const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? ""; + const tableName = (key: string): string | null => + (p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null; + /** 서버에 넘길 표 — 고친 표만 */ + const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]])); + + const name = createInputField({ + label: te("Name"), + value: p.lb?.doc.name ?? p.row.이름, + onInput: (v) => { + if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } }); + }, + }); + name.input.disabled = readOnly; name.root.classList.add("m01-lb__name"); - const drawResult = (pending: boolean): void => { - const unit = p.row.결과단위 ?? ""; - const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; + /** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */ + const later = (host: HTMLElement, draw: () => void, answer: boolean): void => { + const a = document.activeElement; + const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement; + if (answer && typing && host.contains(a)) { + host.addEventListener("focusout", () => setTimeout(draw), { once: true }); + return; + } + draw(); + }; + + const drawSource = (): void => { + if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks"))); + renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), { + edit: onTableEdit, + role: onRole, + }); + }; + const drawBasis = (): void => { + if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks"))); + const on = { change: (lb: LbLogic) => edit(lb) }; + if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName }); + else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial }); + }; + + /** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */ + const draw = (pending: boolean, answer = false): void => { + const unit = unitNow(); + const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; total.textContent = isMoney(unit) - ? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) }) - : te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) }); + ? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) }) + : te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) }); total.classList.toggle("is-pending", pending); sheetHost.classList.toggle("is-pending", pending); - renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans); + unsaved.hidden = !paper?.dirty(); + undo.disabled = !stack.length; + blockedNote.textContent = p.lb?.blocked.length + ? `${te("Blocked")} ${p.lb.blocked.join(" · ")}` + : ""; + renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans); + later(sourceHost, drawSource, answer); + later(basisHost, drawBasis, answer); }; const calc = (): void => { - drawResult(true); - lbCalcLater(p.row, p.trial, p.tables, (next, plain) => { + draw(true); + lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => { ans = next; note.textContent = plain ?? ""; - drawResult(false); + draw(false, true); }); }; + const drawExamplesNow = (): void => + drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => { + p.trial = next; + calc(); + }); + + /** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */ + const edit = (next: LbLogic): void => { + if (readOnly || next === p.lb) return; + stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; + const inputsChanged = next.inputs !== p.lb?.inputs; + p.lb = next; + if (inputsChanged) { + p.trial = { ...autoTrial(next.inputs), ...p.trial }; + drawExamplesNow(); + } + void loadTables(); + calc(); + }; + + /** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */ + const onTableEdit = (e: TableEdit): void => { + const t = p.tables[e.table] as { 줄: Record[] } | undefined; + if (!t) return; + stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; + const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r)); + p.tables = { ...p.tables, [e.table]: { ...t, 줄 } }; + p.changed = new Set([...p.changed, e.table]); + calc(); + }; + + /** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */ + const onRole = (table: string, col: string, role: LbRole): void => { + const lb = p.lb; + if (!lb || readOnly) return; + if (role === "결과") return void (note.textContent = te("Role_Result")); + const hits = lookupHits(lb, p.tables, p.trial); + const rows = (p.tables[table] as { 줄?: Record[] } | undefined)?.줄; + let next = lb; + lb.lookups.forEach((lk, i) => { + if (lk.table !== table || lk.col === col) return; + const id = next.lookups[i]?.id ?? lk.id; + if (role === "조건") { + if (lb.inputs.some((x) => x.이름 === col)) + next = setCond(next, id, col, { k: "input", name: col }); + return; + } + const hit = hits[lk.id]; + const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col]; + const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell; + if (typeof value === "number" || typeof value === "string") + next = setCond(next, id, col, { k: "fixed", value }); + }); + if (next === lb) note.textContent = te("Role_NoInput", { v: col }); + else edit(next); + }; + + /** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */ + const loadTables = async (): Promise => { + const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])]; + const want = keys.filter((k) => !(k in p.tables)); + if (!want.length) return; + const got = await Promise.all( + want.map((k) => + fetchLbTable(k).then( + (t) => [k, t.table] as const, + () => [k, undefined] as const, + ), + ), + ); + p.tables = { ...p.tables, ...Object.fromEntries(got) }; + drawSource(); + drawBasis(); + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + ({ lb: p.lb, tables: p.tables, changed: p.changed } = back); + name.input.value = p.lb?.doc.name ?? p.row.이름; + drawExamplesNow(); + calc(); + }; + + /** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */ + const reopen = (row: LogicRow, key: string, version: string): void => { + const lb = blocksOf(row, key, version, false); + showPaper({ key, version, isBase: false, row, lb, trial: p.trial }); + }; + + const doSave = async (): Promise => { + if (readOnly || busy || !p.lb) return; + const row = writeLogic(p.lb); + if (!row.이름.trim()) return void (note.textContent = te("NoName")); + busy = true; + note.textContent = te("Saving"); + try { + const saved = + p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row); + logics = null; + showToast(te("Saved"), "success"); + reopen(saved.row, saved.key, saved.version); + } catch (error) { + note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; + } finally { + busy = false; + } + }; + const doCopy = async (): Promise => { - if (busy) return; + if (!p.key || busy) return; busy = true; note.textContent = te("Copying"); try { const saved = await copyEasy(p.key); - paper = { - key: saved.key, - version: saved.version, - isBase: false, - row: saved.row, - trial: p.trial, - lb: blocksOf(saved.row, saved.key, saved.version, false), - tables: {}, - }; logics = null; showToast(te("Copied"), "success"); - showPaper(); + reopen(saved.row, saved.key, saved.version); } catch (error) { note.textContent = plainError(error); } finally { @@ -327,44 +525,80 @@ function openLogicBuilder(): void { } }; - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; + const doDelete = async (): Promise => { + if (!p.key || !p.version || p.isBase || busy) return; + busy = true; + note.textContent = te("Uses_Wait"); + try { + const uses = (await usesOf(p.key)).filter((u) => u.key); + if (uses.length) { + note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") }); + return; + } + note.textContent = ""; + const ask = te("Delete_Ask", { v: rowNow().이름 }); + if (!(await showConfirmDialog(ask, te("Delete")))) return; + await deleteEasy(p.key, p.version); + logics = null; + paper = null; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (error) { + note.textContent = plainError(error); + } finally { + busy = false; + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + const fieldActions = [ + undo, + createButton({ label: te("Save"), onClick: () => void doSave() }), + ...(p.key + ? [ + createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), + }), + ] + : []), + close, + ]; const actions = el("div", { className: "m01-lb__actions", - children: [ - ...(p.isBase - ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })] - : [undo, createButton({ label: te("Save"), onClick: notYet })]), - createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }), - ], + children: p.isBase + ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close] + : fieldActions, }); - const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => { + const viewButtons = (["sentence", "grid"] as View[]).map((which) => { const b = createButton({ - label, + label: te(which === "sentence" ? "View_Sentence" : "View_Grid"), variant: view === which ? "filled" : "ghost", size: "sm", onClick: () => { view = which; - showPaper(); + for (const x of viewButtons) { + const on = x.dataset.view === view; + x.classList.toggle("ui-btn--filled", on); + x.classList.toggle("ui-btn--ghost", !on); + x.setAttribute("aria-pressed", String(on)); + } + drawBasis(); }, }); + b.dataset.view = which; b.setAttribute("aria-pressed", String(view === which)); return b; - }; - - sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); - basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); - drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => { - p.trial = next; - calc(); }); body.replaceChildren( @@ -382,25 +616,33 @@ function openLogicBuilder(): void { ], }), note, - ...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []), + ...(p.isBase ? [muted(te("StandardNote"))] : []), el("div", { className: "m01-lb__cols", children: [ column(te("Col_Source"), [sourceHost]), - column( - te("Col_Basis"), - [basisHost], - [viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))], - ), + column(te("Col_Basis"), [blockedNote, basisHost], viewButtons), column(te("Col_Result"), [ total, examplesHost, el("h5", { text: te("Sheet") }), sheetHost, + checksHost, ]), ], }), ); + drawExamplesNow(); + if (p.lb) { + renderChecks(checksHost, { + lb: () => p.lb as LbLogic, + tables: () => p.tables, + trial: () => p.trial, + ans: () => ans, + draftKey: p.key ?? "새", + }); + } + void loadTables(); calc(); }; @@ -412,11 +654,24 @@ function openLogicBuilder(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } +/** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */ +function autoTrial(inputs: LogicInput[]): Record { + const out: Record = {}; + for (const spec of inputs) { + if (String(spec.단위 ?? "").includes("원")) continue; + out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); + } + return out; +} + +const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text }); + /** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement { return el("section", { @@ -431,62 +686,6 @@ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = [ }); } -/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ -function drawExamples( - host: HTMLElement, - inputs: LogicInput[], - trial: Record, - onChange: (trial: Record) => void, -): void { - host.hidden = !inputs.length; - if (!inputs.length) return void host.replaceChildren(); - const state = { ...trial }; - const field = (spec: LogicInput): HTMLElement => { - const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; - let box: HTMLInputElement | HTMLSelectElement; - if (spec.고르기?.length) { - box = el("select", { - className: "ui-select ui-select--sm", - children: [ - el("option", { attrs: { value: "" }, text: te("Pick") }), - ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), - ], - }); - box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - const picked = spec.고르기?.find((v) => String(v) === box.value); - if (picked === undefined) delete state[spec.이름]; - else state[spec.이름] = picked; - onChange({ ...state }); - }); - } else { - box = el("input", { - className: "ui-input ui-input--sm", - attrs: { - type: "number", - ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), - }, - }); - box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - if (box.value === "") delete state[spec.이름]; - else state[spec.이름] = Number(box.value); - onChange({ ...state }); - }); - } - box.setAttribute("aria-label", spec.이름); - return el("label", { - className: "m01-lb__example", - children: [el("span", { text: label }), box], - }); - }; - host.replaceChildren( - el("strong", { text: te("Examples") }), - el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), - el("p", { className: "m01-lb__muted", text: te("Examples_Note") }), - ); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-lb__big"); diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts new file mode 100644 index 000000000..14c22a068 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * M01_LB_Entry_Result.ts + * 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름) + * 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbTables } from "./M01_LB_Api"; +import type { LbLogic } from "./M01_LB_Blocks"; +import { + gapSkipped, + gaps, + loadExpectRows, + pickValues, + runExpect, + saveExpectRows, + type ExpectRow, +} from "./M01_LB_Check"; + +const TEXT = { + Examples: ["예시값", "Example values"], + Examples_Note: [ + "예시값은 계산을 보려는 값 — 저장 안 됨", + "Example values are only for trying — not saved", + ], + Pick: ["선택", "Choose"], + Gaps: ["빈틈 검사", "Check for gaps"], + Gaps_None: [ + "빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음", + "No gaps — every combination finds one row", + ], + Gaps_No: ["해당 없음", "No match"], + Gaps_Dup: ["중복", "Duplicate"], + Gaps_Rows: ["줄 {v}", "rows {v}"], + Gaps_Skip: [ + "못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음", + "{n} lookups not checked — numeric inputs", + ], + Gaps_More: ["그 밖 {n} 건", "{n} more"], + Expect: ["기대값 시험표", "Expected-value table"], + Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"], + Expect_Run: ["모두 돌리기", "Run all"], + Expect_Total: ["합계", "Total"], + Expect_Ok: ["맞음", "Match"], + Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"], + Expect_Del: ["삭제", "Delete"], + Expect_None: [ + "줄 없음 — 예시값을 정한 뒤 한 줄 더하기", + "No rows — set examples, then add a row", + ], + Expect_Note: [ + "시험표는 이 화면에만 남음 — 저장 안 됨", + "The table stays on this screen only — not saved", + ], +} as const; + +type TextKey = keyof typeof TEXT; + +const tr = (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 totalKey = (ans: CalcAnswer | null): "계" | "결과" => + ans?.ok && ans.result !== undefined ? "결과" : "계"; + +/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ +export function drawExamples( + host: HTMLElement, + inputs: LogicInput[], + trial: Record, + onChange: (trial: Record) => void, +): void { + host.hidden = !inputs.length; + if (!inputs.length) return void host.replaceChildren(); + const state = { ...trial }; + const field = (spec: LogicInput): HTMLElement => { + const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; + let box: HTMLInputElement | HTMLSelectElement; + if (spec.고르기?.length) { + box = el("select", { + className: "ui-select ui-select--sm", + children: [ + el("option", { attrs: { value: "" }, text: tr("Pick") }), + ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), + ], + }); + box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + const picked = spec.고르기?.find((v) => String(v) === box.value); + if (picked === undefined) delete state[spec.이름]; + else state[spec.이름] = picked; + onChange({ ...state }); + }); + } else { + box = el("input", { + className: "ui-input ui-input--sm", + attrs: { + type: "number", + ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), + }, + }); + box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + if (box.value === "") delete state[spec.이름]; + else state[spec.이름] = Number(box.value); + onChange({ ...state }); + }); + } + box.setAttribute("aria-label", spec.이름); + return el("label", { + className: "m01-lb__example", + children: [el("span", { text: label }), box], + }); + }; + host.replaceChildren( + el("strong", { text: tr("Examples") }), + el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), + el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }), + ); +} + +const condText = (conds: Record): string => + Object.entries(conds) + .map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`) + .join(" · "); + +/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */ +export function renderChecks( + host: HTMLElement, + s: { + lb(): LbLogic; + tables(): LbTables; + trial(): Record; + ans(): CalcAnswer | null; + draftKey: string; + }, +): void { + const gapHost = el("div", { className: "m01-lb__check-out" }); + const rowsHost = el("div", { className: "m01-lb__check-out" }); + let rows: ExpectRow[] = loadExpectRows(s.draftKey); + let results: (boolean | number | null)[] = []; + const keep = (): void => saveExpectRows(s.draftKey, rows); + + const runGaps = (): void => { + const found = gaps(s.lb(), s.tables()); + const skipped = gapSkipped(s.lb(), s.tables()).length; + const lines = found.slice(0, 20).map((g) => + el("li", { + text: [ + g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"), + condText(g.conds), + g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "", + ] + .filter(Boolean) + .join(" — "), + }), + ); + gapHost.replaceChildren( + found.length + ? el("ul", { children: lines }) + : el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }), + ...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []), + ...(skipped + ? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })] + : []), + ); + }; + + const drawRows = (): void => { + if (!rows.length) { + rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") })); + return; + } + rowsHost.replaceChildren( + ...rows.map((row, i) => { + const key = Object.keys(row.expect)[0] ?? "계"; + const box = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "number", "aria-label": tr("Expect_Total") }, + }); + box.value = String(row.expect[key] ?? ""); + box.addEventListener("change", () => { + row.expect = { [key]: Number(box.value) }; + results[i] = null; + keep(); + }); + const r = results[i]; + return el("div", { + className: "m01-lb__expect-row", + children: [ + el("span", { text: condText(row.inputs) || "—" }), + box, + el("span", { + className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "", + text: + r === true + ? tr("Expect_Ok") + : typeof r === "number" + ? tr("Expect_Bad", { v: formatNumber(r) }) + : "", + }), + createButton({ + label: tr("Expect_Del"), + variant: "ghost", + size: "sm", + onClick: () => { + rows = rows.filter((_, j) => j !== i); + results = results.filter((_, j) => j !== i); + keep(); + drawRows(); + }, + }), + ], + }); + }), + ); + }; + + const addRow = (): void => { + const ans = s.ans(); + const key = totalKey(ans); + const now = ans?.ok ? pickValues(ans)[key] : undefined; + rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }]; + keep(); + drawRows(); + }; + + const runRows = async (): Promise => { + const got = await runExpect(s.lb(), s.tables(), rows); + results = got.map((g, i) => { + const key = Object.keys(rows[i].expect)[0] ?? "계"; + return g.ok ? true : (g.got[key] ?? null); + }); + drawRows(); + }; + + host.replaceChildren( + el("div", { + className: "m01-lb__check", + children: [ + createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }), + gapHost, + el("strong", { text: tr("Expect") }), + el("div", { + className: "m01-lb__check-tools", + children: [ + createButton({ + label: tr("Expect_Add"), + variant: "ghost", + size: "sm", + onClick: addRow, + }), + createButton({ + label: tr("Expect_Run"), + variant: "ghost", + size: "sm", + onClick: () => void runRows(), + }), + ], + }), + rowsHost, + el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }), + ], + }), + ); + drawRows(); +} From 57ce7bb21bdf1f2b4f02dec9dfd00096db873ec4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:12:00 +0900 Subject: [PATCH 12/20] =?UTF-8?q?chore(M01):=20=ED=98=84=EC=9E=A5=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20GX000001=20=EB=8F=8C?= =?UTF-8?q?=EC=8C=93=EA=B8=B0=20=EB=A9=94=EC=8C=93=EA=B8=B0(=EC=9D=B8?= =?UTF-8?q?=EB=A0=A5)=20=E2=80=94=20=EB=A9=94=EC=9D=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=ED=99=94=EB=A9=B4=20=ED=8E=B8=EC=A7=91(=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=ED=8E=B8=EC=A7=91=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B4=84)?= 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 --- resources/master_data/_키대장.json | 3 ++- resources/master_data/로직_자체.json | 33 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 resources/master_data/로직_자체.json diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index 1a86176db..da881e552 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 1, "UA": 38}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 2, "UA": 38}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -2628,6 +2628,7 @@ "GF000328": {"원문번호": "13-11-1 원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000329": {"원문번호": "13-11-2 타원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000330": {"원문번호": "13-15-1 목재 껍질벗기기", "파일": "로직_산림품셈_13장_구조물.json"}, + "GX000001": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력)", "파일": "로직_자체.json"}, "LB000001": {"원문번호": "1001", "파일": "인력.json"}, "LB000002": {"원문번호": "1002", "파일": "인력.json"}, "LB000003": {"원문번호": "1003", "파일": "인력.json"}, diff --git a/resources/master_data/로직_자체.json b/resources/master_data/로직_자체.json new file mode 100644 index 000000000..767ef13bd --- /dev/null +++ b/resources/master_data/로직_자체.json @@ -0,0 +1,33 @@ +{ + "그룹": "로직", + "원문": "자체", + "판": "2026", + "줄": [ + { + "키": "GX000001", + "원문번호": "13-4-1", + "구분": "자체", + "상세구분": "13장 구조물", + "이름": "돌쌓기 메쌓기(인력)", + "결과단위": "원/㎡", + "출처": "산림품셈 13-4-1", + "소유": "현장", + "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", + "입력": [ + { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, + { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, + { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, + { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, + { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } + ], + "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], + "호표": [ + { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, + { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } + ], + "덧줄": [], + "끝수": null, + "시험입력": { "높이": 4.0, "돌": "깬잡석" } + } + ] +} From 545602f24de84845824a6c6fa0692fcfd09a5a3a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:19:48 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix(M01-=EB=A1=9C=EC=A7=81=ED=8C=90):=20S?= =?UTF-8?q?teps=20=EB=8B=A4=EB=A5=B8=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9E=84=20-?= =?UTF-8?q?=20=EB=AA=BB=20=EC=B0=BE=EC=9C=BC=EB=A9=B4=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M01_MasterData/LogicBuilder/M01_LB_Steps.ts | 24 ++++++++++++++++++- .../LogicBuilder/M01_LB_Steps_Cell.ts | 7 ++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts index 9e92ed7e1..0dca8c47b 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts @@ -6,7 +6,12 @@ * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { CalcAnswer, CalcLine, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { + fetchLogics, + type CalcAnswer, + type CalcLine, + type LogicInput, +} from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { addCard, @@ -23,6 +28,7 @@ import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Block import { calcBody, calcView } from "./M01_LB_Steps_Calc"; import { chip, + logicNames, lookupValue, note, numBox, @@ -42,6 +48,7 @@ import "./M01_LB_Steps.css"; type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; let picking = false; +let namesLoading = false; /* ------------------------------ ① 입력값 ------------------------------ */ @@ -367,4 +374,19 @@ export function renderSteps( ), section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]), ); + // 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림 + if ( + !logicNames.size && + !namesLoading && + JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"') + ) { + namesLoading = true; + fetchLogics() + .then((all) => { + for (const l of all) logicNames.set(l.키, l.이름); + renderSteps(host, lb, ans, on); + }) + .catch(() => undefined) + .finally(() => (namesLoading = false)); + } } diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts index e3b1ae55d..35a73f203 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts @@ -33,7 +33,7 @@ export const TEXT = { SrcCalc: ["계산 · ", "calc · "], SrcFixed: ["고정값", "fixed value"], SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"], - Other: ["다른 일위대가 결과", "Other unit price result"], + Other: ["다른 일위대가", "Other unit price"], Hard: ["이 화면에서 못 고치는 값", "Value not editable here"], Mul: ["×", "×"], Div: ["÷", "÷"], @@ -197,6 +197,9 @@ export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && export const lookupNo = (ctx: Ctx, id: string): number => ctx.lb.lookups.findIndex((l) => l.id === id) + 1; +/** 로직 이름 — 목록을 한 번 받아 채움(키 → 이름) · 못 받으면 「다른 일위대가」 */ +export const logicNames = new Map(); + export function refText(ctx: Ctx, r: Ref): string { switch (r.k) { case "input": @@ -210,7 +213,7 @@ export function refText(ctx: Ctx, r: Ref): string { return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`; } case "logic": - return `${tx("Other")} ${r.key}${r.field ? ` · ${r.field}` : ""}`; + return `${logicNames.get(r.key) ?? tx("Other")}${r.field ? ` · ${r.field}` : ""}`; case "cost": return tx(`Base_${r.name}`); default: From 89f8af2ad4bd5bac4da19ddf12f16e23dafde3c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:36:31 +0900 Subject: [PATCH 14/20] =?UTF-8?q?chore(M01):=20=ED=98=84=EC=9E=A5=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20GX000002=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=EC=9E=AC=20=C2=B7=20GX000003=20=EB=8F=8C=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EB=A9=94=EC=8C=93=EA=B8=B0=20=E2=80=94=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8F=B4=EB=8D=94=20=ED=99=94=EB=A9=B4=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91(=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=8E=B8=EC=A7=91)?= 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 --- resources/master_data/_키대장.json | 4 ++- resources/master_data/로직_자체.json | 42 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index da881e552..bade8a4aa 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 2, "UA": 38}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 4, "UA": 38}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -2629,6 +2629,8 @@ "GF000329": {"원문번호": "13-11-2 타원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000330": {"원문번호": "13-15-1 목재 껍질벗기기", "파일": "로직_산림품셈_13장_구조물.json"}, "GX000001": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력)", "파일": "로직_자체.json"}, + "GX000002": {"원문번호": "12-34-4 암거이음받침 채움재(T=20㎜)", "파일": "로직_자체.json"}, + "GX000003": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력) (2)", "파일": "로직_자체.json"}, "LB000001": {"원문번호": "1001", "파일": "인력.json"}, "LB000002": {"원문번호": "1002", "파일": "인력.json"}, "LB000003": {"원문번호": "1003", "파일": "인력.json"}, diff --git a/resources/master_data/로직_자체.json b/resources/master_data/로직_자체.json index 767ef13bd..87528c301 100644 --- a/resources/master_data/로직_자체.json +++ b/resources/master_data/로직_자체.json @@ -28,6 +28,48 @@ "덧줄": [], "끝수": null, "시험입력": { "높이": 4.0, "돌": "깬잡석" } + }, + { + "키": "GX000002", + "원문번호": "12-34-4", + "구분": "자체", + "상세구분": "12장 철근콘크리트", + "이름": "암거이음받침 채움재(T=20㎜)", + "결과단위": "원/㎡", + "출처": "산림품셈 12-34-4", + "소유": "현장", + "비고": "해석: 원문 재료비 「JOINT FILLER」 뿐 — ㎡당 1㎡ · 설치 품 원문 없음 · GF000208 를 본뜸", + "입력": [], + "중간": [], + "호표": [{ "종류": "재료", "요소": { "구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER" }, "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비" }], + "덧줄": [], + "끝수": null + }, + { + "키": "GX000003", + "원문번호": "13-4-1", + "구분": "자체", + "상세구분": "13장 구조물", + "이름": "돌쌓기 메쌓기(인력)", + "결과단위": "원/㎡", + "출처": "산림품셈 13-4-1", + "소유": "현장", + "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", + "입력": [ + { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, + { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, + { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, + { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, + { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } + ], + "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], + "호표": [ + { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, + { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } + ], + "덧줄": [], + "끝수": null, + "시험입력": { "높이": 4.0, "돌": "깬잡석" } } ] } From 87f2d04fa7aaf05e9e97472411d0e08fad325c66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:49:45 +0900 Subject: [PATCH 15/20] =?UTF-8?q?chore(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=8B=9C=ED=97=98=ED=8C=90=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=A0=9C=EC=96=B4=20=EC=A0=90=EA=B2=80=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=97=AC?= =?UTF-8?q?=EC=84=AF?= 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 | 63 +++++++++++++++++++ 1 file changed, 63 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..94c353e78 --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_관계제어_점검.md @@ -0,0 +1,63 @@ +# 35-3 점검 — 로직 만들기(시험)에서 「표끼리의 관계」를 초등학생이 제어할 수 있나 (sub6 · 확인만) + +- 대상: 내 포트 8008 · 5181 → M01 → 단가산출 로직 → 「로직 만들기(시험)」. git-sync 로 dev 최신(105 커밋)을 받은 뒤 서버를 다시 띄움. 판정 직전 세션 SYSTEM_ADMIN 확인. +- 시험 로직: 돌쌓기 메쌓기(인력) — 표준 GF000219 은 읽기만이라, 이미 있는 현장 사본 GX000001(같은 식)을 열어 메모리에서만 고침. **저장 · 복사 편집 · 삭제는 안 누름**. 끝에 「실행 취소」 10번으로 모두 되돌린 것 확인. 서버 로직 수 그대로 · git 깨끗. 덤프트럭 시간당 작업량 GF000133 · 그것을 부르는 골재운반 모래 GF000134 는 읽기만. +- 사진: `tmp/로직판_관계_사진/` (r01 표준 열기 · r02 현장 사본 · r03 문턱만 바꿈 → 금액 멈춤 · r04 표 칸까지 고침 → 할증 걸림 · r05 격자 · r06 비율 더하기 · r07 격자 칠하기 · r08 로직 부름). +- 걸음 = 고칠 화면이 열린 뒤 누름 수(칸에 글 넣기 1 번 = 1). 들어가기는 M01 → 단가산출 로직 → 로직 만들기(시험) → 로직 고름 = 3 누름. 표준이면 「복사 편집」 1 더(누르는 순간 서버에 현장 사본이 써짐 · 저장 단추를 거치지 않음). + +## 한 줄 결론 + +**이미 있는 관계의 「값 칸」을 바꾸는 일(문턱 숫자 · 할증율 · 조건을 고정값으로)은 고르기 하나 · 숫자 하나로 되고 식은 안 씀. 그러나 관계 자체를 더하고 · 지우고 · 다른 표나 다른 결과 칸으로 잇는 일은 화면에 단추가 없어 거의 안 됨.** 품셈에 없는 관계(새 입력값 · 새 조건 · 새 표)는 만들 수 없고, 「+ 비율」 한 갈래만 있음. 초등학생 판정은 바꾸기 = 중, 더하기 · 지우기 · 다시 잇기 = 불가. + +## 시나리오 표 + +| # | 할 일 | 됨 | 걸음 | 지나간 화면 | 막힌 곳 | 모를 낱말 | 식을 쓰나 | 초등 | 까닭 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| S1① | 높이 할증 문턱 3m → 2.5m | 일부 | 3 (문턱 칸 1 + 원문 표 칸 「3~4」→「2.5~4」 2) | 산출근거 ③ 계산 · 원문 표 | 문턱만 바꾸면 2.5~3m 에서 증가율 표에 맞는 줄이 없어 금액이 「—」로 멈춤. 표 칸 고침은 「미리 보기 — 저장은 결정 뒤」라 저장되는 것은 문턱뿐. 멈출 때 위 알림은 「서버와 이어지지 않음」(실제 까닭은 「표 QF000422 에 높이 2.7 인 줄이 없음」) | 조건 목록 · 「위에서 처음 맞는 줄의 값을 씀」 · 그 외 · 해당 없음 | 아니요 | 하 | 칸은 쉬우나 문턱과 표 범위가 엮인 것이 안 보이고, 깨진 뒤 까닭이 엉뚱함 | +| S1② | 높이 할증을 석공에만 · 보통인부는 빼기 | 됨(꼼수) | 1 (계산 3 율 칸 「계산 · 증가율」→「고정값」 · 0 이 저절로) | ③ 계산 | 「할증 빼기」 단추는 없음 — 율 0% 로 둠. 계산 3 은 남음 | 바탕 · 율 · 「% 를 더 얹음」 · 계산 · 증가율 | 아니요 | 중 | 문장 「표 조회 3 · 보통인부 에 계산 · 증가율 = 30 % 를 더 얹음」을 읽어야 고를 칸을 찾음 | +| S1③ | 증가율 표 대신 고정 30% | 됨(일부) | 4 (계산 2 · 3 율 칸 → 고정값 → 30 · 두 번) | ③ 계산 | 높이와 상관없이 늘 30%(3m 아래도). 「3m 넘을 때만 30%」는 계산 1 둘째 줄 「표에서 찾은 값」을 숫자로 못 바꿔 안 됨. 안 쓰게 된 계산 1 · 표 조회 1 을 지울 길 없음 | 고정값 · 율 | 아니요 | 중 | 율 칸 둘을 따로 바꿔야 함을 스스로 알아야 함 | +| S2① | 조건 칸 「돌」 입력값 → 고정 「깬돌」 | 됨 | 4 (표 조회 2 「돌」 고르기 → 고정값 → 「깬돌」 글 · 표 조회 3 에 또) | ② 표 조회 | 같은 표를 석공 · 보통인부 두 번 조회 — 한쪽만 바꾸면 석공은 깬돌 · 보통인부는 깬잡석 품으로 섞여 계산됨(실측 121,863원 · 경고 없음). 예시값 뒷길이 25 에는 깬돌 줄이 없어 「—」. 원문 표 칸 역할 알약도 「고정」으로 바뀌지만 어느 조회 것인지 안 보임 | 표 조회 · 무관(조건에서 뺌) · 고정값 | 아니요(글 「깬돌」은 손으로 적음) | 중 | 고르기는 쉬우나 같은 조건이 두 곳에 있음 · 오타면 해당 없음 | +| S2② | 쓰는 표 바꾸기(메쌓기 → 찰쌓기 표) | 안 됨 | — | ② 표 조회 | 표 이름은 글자 조각 — 누를 곳 없음. 바꾸는 함수(`setLookupTable` · `M01_LB_Blocks.ts:627`)는 있으나 화면에 안 붙음 | — | — | 불가 | 화면에 길 없음 | +| S2③ | 결과 칸 바꾸기(석공 → 보통인부 칸) | 안 됨(꼼수 하나) | 원문 칸 알약 1 → 「결과 칸 바꾸기는 아직 안 됨」 | 원문 · ② 표 조회 | 「인 줄의 석공」은 글자 조각. 알약은 조건 → 고정 → 결과로 돌고, 결과 칸을 조건으로 돌리면 「「석공」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함」. 꼼수: 계산 2 바탕을 「표 조회 3 · 보통인부」로 고르면 석공 줄이 보통인부 품을 씀(고르기 목록에 있음 · 실측 안 함) | 결과 · 바탕 | 아니요 | 불가 | 표 조회의 결과 칸을 고르는 곳이 없음 | +| S3① | 새 입력값 「경사」(완 / 급) · 「급이면 석공 품 10% 더함」 | 안 됨 | — | ① 입력값 · ③ 계산 | 입력값 더하기 · 지우기 단추 없음(① 은 단위 · 범위만 고침 · 선택 목록 낱말도 못 고침). 계산 더하기 없음. 단계 안내(새로 만들기)에서만 숫자 입력값을 「입력값으로 받음」으로 만들 수 있음 — 선택(완/급) 꼴 아님 · 모든 결과 칸에 같이 걸림 | — | — | 불가 | 화면에 길 없음 | +| S3② | 품셈에 없는 작은 표 만들어 잇기 | 안 됨 | — | 원문 | 표 만들기 화면 없음(이 판 · 옛 M01 표 화면 모두). 원문 표 칸 고침은 미리 보기만 · 줄 더하기도 없음 | — | — | 불가 | 표는 품셈 원문에서만 옴 | +| S3③ | 새 항목 「잡재료 = 노무비의 2%」 | 일부 | 2 (「+ 비율」 · 2 입력 · 바탕 노무비는 기본) | ④ 항목 연결 | 2,437원 더해짐. 그러나 이름 칸 없음(일위대가표에 이름 빈 「1 식」) · 넣을 자리 고르기 없음 → 경비로 들어감(잡재료는 재료비여야). 단계 안내에서는 이름 · 넣을 자리 있음 | 비율 · 노무 줄의 합 | 아니요 | 중 | 덧붙임 한 갈래만 · 이름 · 자리 못 정함 | +| S4 | 관계 지우기(할증 · 표 조회 · 입력값) | 안 됨 | — | ② ③ ① | 할증 · 표 조회 · 입력값 · 계산 모두 지움 단추 없음(할증은 율 0 꼼수). 항목 「지움」은 1 누름 · 안 물음 — 보통인부를 지워도 그 줄이 쓰던 표 조회 3 · 계산 3 이 화면에 남고 알림 없음. 「실행 취소」 10번으로 표 칸 고침까지 다 되돌아옴(확인) · 로직 삭제는 쓰는 곳 검사 뒤 물음 | — | 아니요 | 불가 (항목만 상) | 관계를 지우는 길이 없음 | +| S5 | 관계 한눈에 보기 | 일부 | 0 (보기 바꿈 1) | 문장 · 격자 · 원문 | **문장 보기**: ① → ⑤ 차례 · 「표 조회 2 메쌓기(인력) 에서 뒷길이 이 입력값 · 뒷길이 … 인 줄의 석공」 → 「계산 2 석공 수량 표 조회 2 · 석공 에 계산 · 증가율 = 30 % 를 더 얹음」 → 「【노무】 석공 이 계산 2 = 0.195 인 = 52,437 원」 — 번호를 거꾸로 따라가면 길이 보이나 선 · 화살표 없음. **격자 보기**: 줄을 누르면 그 줄이 쓰는 줄 하나만 칠함(석공 → 계산 2 · 표 조회 · 입력값까지 안 이어짐) · 값 칸이 오른쪽에 잘려 고정값(30 · 깬돌)이 안 보임 · 표 이름 대신 QF000421. **원문**: 「이 표를 쓰는 로직 1 개」 · 주③ 「계산에 쓰임」 · 칸 역할 알약 | 표 조회 1 · 2 · 3 번호 · 바탕 · QF 번호 | 아니요 | 중 | 읽을 수는 있으나 한 장 그림이 아님 | +| S6 | 로직끼리 관계(작업량 로직 → 받는 로직) | 보기 일부 · 바꾸기 안 됨 | 0 | 산출근거 | 골재운반 모래(GF000134) 에 「계산 1 작업량 = 60 · 골재운반 덤프트럭 시간당 작업량」 글자만. 넘기는 값(qt=qt · f=f · E=E · Cm=Cm) 안 보임 · 눌러서 그 로직 열기 없음 · 고르기 목록에 「다른 로직」 없음. GF000133 쪽에 「이 로직을 쓰는 로직」 목록 없음(표에만 있음 · 실제는 GF000134 · 135 · 136 셋). 계산 2 문장이 「1 = 1 ÷ 작업량 = 60」으로 읽기 어색 | qt · f · E · Cm(기호 그대로) | 아니요 | 하 | 보이기만 하고 따라가기 · 바꾸기 길 없음 | + +## 안 되는 것이 왜 안 되나 (코드 근거) + +1. 블록 모형이 「있는 관계 고치기」 세 함수뿐 — `setCond`(조건 칸 출처) · `setLookupTable`(쓰는 표 · 화면에 안 붙음) · `setCalc`(계산 조각 · 틀) — `M01_LB_Blocks.ts:604-645`. 표 조회 · 계산 · 입력값을 **더하거나 지우는 함수가 없음**. 고침은 원래 식 글의 그 자리만 갈아끼우는 방식이라 새 자리를 만들 수 없음. +2. 화면 쪽도 같은 범위 — 문장 보기는 항목 카드만 더하기 · 지우기(`M01_LB_Steps.ts:172` `addCard` · `:249` `removeCard`) · 격자 보기는 `setCond` · `setCalc` 만(`M01_LB_Grid.ts:369-412`) · 계산 틀(할증 → 값 등)은 못 바꿈. +3. 값 고르기 목록 = 입력값 · 표 조회 · 다른 계산 · 고정값(숫자)만(`M01_LB_Steps_Cell.ts:235-275`). 「다른 로직」 · 「글 고정값」은 목록에 없어 로직 부름 줄은 글자 조각으로만 보임. +4. 표 조회의 결과 칸 바꾸기는 「아직 안 됨」으로 막아 둠(`M01_LB_Entry.ts:86` · `:437`). +5. 표 칸 고침은 정함 5대로 메모리 사본 · 저장 안 함 — 그래서 S1① 처럼 관계와 표 범위를 함께 바꿔야 하는 일은 저장 뒤 깨짐. +6. 새 입력값 · 새 표 · 조건 「급이면」 같은 품셈에 없는 관계는 단계 안내(새로 만들기)에만 일부(숫자 입력값 · 품셈 절의 다른 표로 할증 조건 목록) — 고치는 화면에는 길이 없음. +7. 「+ 비율」 카드에는 이름 · 넣을 자리 칸이 없음(단계 안내의 덧줄은 있음) — 같은 뜻의 두 길이 서로 다름. + +## 지금 화면(옛 단가산출)에서는 + +공통: 표준 로직은 못 고침 → 「본떠 만들기」로 자체 사본을 만든 뒤 편집기에서 식 글을 고침(34-1B 실측 + 코드). 거의 다 되지만 모두 **식을 써야 함 → 초등 하**. + +| # | 옛 화면 | 어떻게 | +| --- | --- | --- | +| S1① | 됨 · 식 | 중간 값 식 `만약(높이 <= 3, 0, …)` 의 3 → 2.5 (표 범위 문제는 같음) | +| S1② | 됨 · 식 | 보통인부 수량 식 끝 `* (1 + 증가율 / 100)` 지움 | +| S1③ | 됨 · 식 | 수량 식을 `… * 1.3` 으로 · 또는 중간 값 증가율 = 30 | +| S2① | 됨 · 식 | `찾기(QF000421, …, 돌=돌, …)` → `돌='깬돌'` (두 줄 다) | +| S2② | 됨 · 식 | 식 안 QF 번호 고침 · 또는 「표」 단추(수량 칸을 통째로 바꿈) | +| S2③ | 됨 · 식 | 식 끝 `.석공` → `.보통인부` | +| S3① | 됨 · 식 | 설계에서 받을 값 「줄 추가」(이름 경사 · 고르기 「완, 급」) + 수량 식에 `* 만약(경사 == '급', 1.1, 1)` | +| S3② | 안 됨 | 표 만들기 없음 · `만약` 식으로 흉내만 | +| S3③ | 됨 · 식 조금 | 덧줄 「줄 추가」 · 이름 잡재료 · 식 `노무비 * 0.02` · 비목 재료비 | +| S4 | 됨 · 안 물음 | 칸마다 × — 쓰는 곳 검사 없음 · 저장 때 엔진 검사(422)로만 걸림 | +| S5 | 일부 | 텍스트 수식(이름 식 · 값 식 두 칸) · 알약 누르면 표 창 | +| S6 | 됨 · 식 | 중간 값 `로직(GF000133, qt=qt, f=f, E=E, Cm=Cm)` 글 고침 · 알약 「로직」 누르면 그 로직을 엶 | + +## 덧붙여 본 것 + +- 계산이 멈추면 위 알림이 「서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요」 — 서버는 `ok:true` 와 줄마다 까닭(「표 QF000422(13-4-1)에 높이 2.7 인 줄이 없음」)을 줌. 원문 표 위의 「해당 없음 — 예시값에 맞는 줄이 없음」만 맞는 말. +- 칸 고침은 글을 넣고 칸을 떠나야(Enter · 다른 곳 누름) 반영됨. +- 고친 뒤 처음 값으로 다 되돌리면 「닫기」가 묻지 않고 닫힘(맞는 동작) · 「저장 안 함」 표시는 그대로 남아 있었음. +- 원문 칸 역할 알약은 조건 → 고정 → 결과로 돌기만 하고 무엇이 바뀌는지 미리 말하지 않음. From 89220e3d3fee10c54ec1465fdf0fa8e335a64879 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:57:09 +0900 Subject: [PATCH 16/20] =?UTF-8?q?chore(M01):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=8B=9C=ED=97=98=ED=8C=90=20=EC=A0=95?= =?UTF-8?q?=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..a3f21d53f --- /dev/null +++ b/docs/raw/M01_쉬운단가/시트로직_정함.md @@ -0,0 +1,38 @@ +# 시트 로직 시험판 — 정함 (PLAN 35-5 · 브레인 · 10-01 오후) + +사용자 허락(그대로): 「일단 이해한대로 시험판을 만들어보자. 완전히 새로운 품셈을 사용자가 만들려면 기존에 있는 정보를 가져올수도 있지만 새로 만들기도 되어야하면 그럴려면 저장이 되어야할것같아. 일단은 시험판이니까 임시폴더 내부에 저장하고 이상하면 한번에 삭제하는거야.」 +제안서 = https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 근거 = 같은 폴더 `로직판_관계제어_점검.md`(지금 시험판은 관계 더하기 · 지우기 · 다시 잇기 · 새 표가 안 됨) · `로직판_엑셀로직_지도.md` 2 · 3절(우리 스프레드시트 엔진 · 서버 Node 다시 셈). + +## 목적 + +사용자가 코딩 · 브레인 없이, 품셈에 묶이지 않고 단가산출 로직을 새로 만들고 · 기존 정보(품셈 표 · 노임 · 자재 · 장비 · 기존 로직)를 가져오고 · 관계를 더하고 · 바꾸고 · 지움. 관계 = 진짜 엑셀 수식 · 입출력 칸 = 알약. + +## 정함 + +1. **로직 한 벌 = 통합문서 하나**(우리 스프레드시트 Workbook) · **상자 하나 = 시트 하나** · **알약 = 이름 붙은 칸**(입력 알약 · 출력 알약). +2. **선 = 입력 알약 칸에 다른 시트 칸 참조 수식을 저절로 씀**(`='표 · 메쌓기'!E7`) — 엔진이 시트 사이 참조를 이미 셈 · 선을 지우면 그 수식을 지움 · 순환은 엔진 `#CYCLE!` + 그을 때 막음. +3. 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과. 모든 상자 아래 판은 같은 시트 편집기 + 종류별 손잡이. +4. **바깥 값 넣기** — 품셈 표 줄 · 노임 · 자재 · 장비 단가 · 기존 로직 결과는 시트에 「바깥 값」 칸으로 넣음: 화면은 열 때 API 로 받아 넣고, 저장 때 서버가 마스터에서 다시 넣은 뒤 셈(브라우저 값 안 받아 적음). 품셈 표는 사본으로 들고 있지 않음(열 때 · 저장 때 마스터에서 새로). +5. **계산 = 우리 스프레드시트 엔진 한 벌** — 화면은 브라우저, 저장은 서버가 같은 TS 엔진을 Node 로(`common_util/common_util_spreadsheet.py` 길 · 계산 자리 ②). 지금 로직 1,469개(파이썬 엔진)는 안 건드림. +6. **저장 = 임시 폴더** `M01_MasterData/SheetLogic/_data/` (폴더 안 `.gitignore` 로 git 밖) · 키 = `SL-0001` 꼴(자체 번호 · `_키대장.json` 안 씀) · 사용자 표도 여기. 폴더 `SheetLogic/` 를 지우면 코드 · 자료가 한 번에 사라짐. +7. **폴더 밖** — 단추 한 곳(LogicLab.ts 가져오기 한 줄 + 부르기 한 줄 · 「로직 만들기(시험)」 옆 「시트 로직(시험)」) · 서버 등록 한 줄(main.py). 기존 서버 · 엔진 · 마스터 파일 · EasyCalc · LogicBuilder 파일은 안 고침(가져다 쓰기만). +8. **화면** — 위 가로 다이어그램 띠(입력값 → 표 → 계산 → 항목 → 결과 · 단계마다 세로 쌓기 · 자리는 저절로 · 상자 양옆에 입력 · 출력 알약 · 출력 알약을 끌어 입력 자리에 놓으면 선) · 아래 왼쪽 = 고른 상자의 시트 판 · 아래 오른쪽 = 결과(합계 · 노무비 · 재료비 · 경비 · 일위대가표 · 예시값). +9. **가져오기** — 기존 로직을 시트 로직으로 바꿔 가져옴(LogicBuilder Blocks 로 읽고 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · 표 찾기 → 표 상자 + XLOOKUP 또는 INDEX/MATCH) · 시험 로직 6개(GF000208 · GF000219 · GF000025 · GF000249 · GF000133 · GC000051)는 바꾼 값 = 지금 /calc 값이어야 함. +10. **용어** — 시트 로직 · 상자 · 선 · 입력 알약 · 출력 알약 · 입력값 · 고정값 · 표 · 계산 · 항목(노무 · 재료 · 장비 · 일위대가 · 비율) · 다른 로직 · 결과 · 예시값 · 품셈 표 / 내 표 · 노무비 · 재료비 · 경비. + +## 파일 · 창 (모두 `M01_MasterData/SheetLogic/` · 파일마다 700줄 안) + +| 파일 | 창 | 하는 일 | +| --- | --- | --- | +| `M01_SL_Model.ts` · `M01_SL_Eval.ts` | sub7 | 타입(먼저 올림) · 상자 더하기 / 지우기(시트 만들기 · 지우기) · 선 잇기 / 끊기(참조 수식 쓰기 · 지우기) · 알약 정하기 · 검사(순환 · 빈 입력) · 화면 계산(바깥 값 넣기 → 엔진 → 출력 알약 · 결과 읽기) | +| `M01_SL_Router.py` · `M01_SL_Api.ts` · `.gitignore` | sub4 | `_data` 목록 · 열기 · 저장 · 지우기 · 저장 때 바깥 값 다시 넣고 Node 로 셈 · 바깥 값 한 번에 받기(표 · 노임 · 자재 · 장비 · 기존 로직 결과) · 등록 한 줄 | +| `M01_SL_Diagram.ts` + css | sub8 | 위 가로 띠 · 상자 · 알약 · 선(끌어 잇기 · 고르기 · 지우기) · 「+」 상자 더하기 · 이어진 선 진하게 | +| `M01_SL_Panel.ts` + css | sub2 | 아래 시트 판(우리 스프레드시트 편집기 넣기) · 칸 → 입력 / 출력 알약 정하기 · 칸 위 알약 표시 · 틀 단추(할증 · 나누기 · 만약 · 표에서 찾기 — 진짜 수식을 넣어 줌) | +| `M01_SL_Table.ts` + css | sub3 | 표 상자 손잡이 — 품셈 표 고르기(절 · 이름 찾기) → 시트에 넣기 · 내 표(빈 표 · 엑셀 붙여넣기) · 조건 칸 / 결과 칸 알약 · 「표에서 찾기」 수식 돕기 | +| `M01_SL_Item.ts` · `M01_SL_Result.ts` + css | sub5 | 항목 상자 손잡이(노무 · 재료 · 장비 · 일위대가 고르기 → 단가 바깥 값 · 수량 알약 · 노무비 / 재료비 / 경비 출력) · 결과 칸(합계 · 비목 · 일위대가표 · 예시값) | +| `M01_SL_Import.ts` | sub6 | 기존 로직 → 시트 로직(정함 9) · 6개 같은 값 시험 | +| `M01_SL_Entry.ts` + css | sub1 | 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 세 칸 틀 · 저장 · 지우기 · 실행 취소 · 저장 안 함 물음 · 잇기 · 끝 검증 | + +## 끝 검증 (sub1 · 사용자처럼) + +관계 제어 점검 여섯이 모두 되어야 함 — S1 할증 문턱 · 석공에만 · 고정 30% · S2 조건을 고정값으로 · 쓰는 표 바꾸기 · 결과 칸 바꾸기 · S3 새 입력값 「경사」 + 「급이면 10%」 · 내 표 만들어 잇기 · 새 항목 · S4 관계 지우기 · S5 한눈에 보기 · S6 로직끼리 잇기 + 완전히 새 로직 하나(품셈 없이) 만들어 저장 → 다시 열기 → 지우기 · 기존 로직 6개 가져와 값 같음 · 저장 때 서버 값 = 화면 값. From cb093eb21a5afbf40a1b3f55e31549155b7eba26 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:58:11 +0900 Subject: [PATCH 17/20] auto: 2026-10-01 12:58 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/로직판_관계제어_점검.md | 63 +++++++++++++++++++ docs/raw/M01_쉬운단가/시트로직_정함.md | 38 +++++++++++ 2 files changed, 101 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/로직판_관계제어_점검.md 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..94c353e78 --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_관계제어_점검.md @@ -0,0 +1,63 @@ +# 35-3 점검 — 로직 만들기(시험)에서 「표끼리의 관계」를 초등학생이 제어할 수 있나 (sub6 · 확인만) + +- 대상: 내 포트 8008 · 5181 → M01 → 단가산출 로직 → 「로직 만들기(시험)」. git-sync 로 dev 최신(105 커밋)을 받은 뒤 서버를 다시 띄움. 판정 직전 세션 SYSTEM_ADMIN 확인. +- 시험 로직: 돌쌓기 메쌓기(인력) — 표준 GF000219 은 읽기만이라, 이미 있는 현장 사본 GX000001(같은 식)을 열어 메모리에서만 고침. **저장 · 복사 편집 · 삭제는 안 누름**. 끝에 「실행 취소」 10번으로 모두 되돌린 것 확인. 서버 로직 수 그대로 · git 깨끗. 덤프트럭 시간당 작업량 GF000133 · 그것을 부르는 골재운반 모래 GF000134 는 읽기만. +- 사진: `tmp/로직판_관계_사진/` (r01 표준 열기 · r02 현장 사본 · r03 문턱만 바꿈 → 금액 멈춤 · r04 표 칸까지 고침 → 할증 걸림 · r05 격자 · r06 비율 더하기 · r07 격자 칠하기 · r08 로직 부름). +- 걸음 = 고칠 화면이 열린 뒤 누름 수(칸에 글 넣기 1 번 = 1). 들어가기는 M01 → 단가산출 로직 → 로직 만들기(시험) → 로직 고름 = 3 누름. 표준이면 「복사 편집」 1 더(누르는 순간 서버에 현장 사본이 써짐 · 저장 단추를 거치지 않음). + +## 한 줄 결론 + +**이미 있는 관계의 「값 칸」을 바꾸는 일(문턱 숫자 · 할증율 · 조건을 고정값으로)은 고르기 하나 · 숫자 하나로 되고 식은 안 씀. 그러나 관계 자체를 더하고 · 지우고 · 다른 표나 다른 결과 칸으로 잇는 일은 화면에 단추가 없어 거의 안 됨.** 품셈에 없는 관계(새 입력값 · 새 조건 · 새 표)는 만들 수 없고, 「+ 비율」 한 갈래만 있음. 초등학생 판정은 바꾸기 = 중, 더하기 · 지우기 · 다시 잇기 = 불가. + +## 시나리오 표 + +| # | 할 일 | 됨 | 걸음 | 지나간 화면 | 막힌 곳 | 모를 낱말 | 식을 쓰나 | 초등 | 까닭 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| S1① | 높이 할증 문턱 3m → 2.5m | 일부 | 3 (문턱 칸 1 + 원문 표 칸 「3~4」→「2.5~4」 2) | 산출근거 ③ 계산 · 원문 표 | 문턱만 바꾸면 2.5~3m 에서 증가율 표에 맞는 줄이 없어 금액이 「—」로 멈춤. 표 칸 고침은 「미리 보기 — 저장은 결정 뒤」라 저장되는 것은 문턱뿐. 멈출 때 위 알림은 「서버와 이어지지 않음」(실제 까닭은 「표 QF000422 에 높이 2.7 인 줄이 없음」) | 조건 목록 · 「위에서 처음 맞는 줄의 값을 씀」 · 그 외 · 해당 없음 | 아니요 | 하 | 칸은 쉬우나 문턱과 표 범위가 엮인 것이 안 보이고, 깨진 뒤 까닭이 엉뚱함 | +| S1② | 높이 할증을 석공에만 · 보통인부는 빼기 | 됨(꼼수) | 1 (계산 3 율 칸 「계산 · 증가율」→「고정값」 · 0 이 저절로) | ③ 계산 | 「할증 빼기」 단추는 없음 — 율 0% 로 둠. 계산 3 은 남음 | 바탕 · 율 · 「% 를 더 얹음」 · 계산 · 증가율 | 아니요 | 중 | 문장 「표 조회 3 · 보통인부 에 계산 · 증가율 = 30 % 를 더 얹음」을 읽어야 고를 칸을 찾음 | +| S1③ | 증가율 표 대신 고정 30% | 됨(일부) | 4 (계산 2 · 3 율 칸 → 고정값 → 30 · 두 번) | ③ 계산 | 높이와 상관없이 늘 30%(3m 아래도). 「3m 넘을 때만 30%」는 계산 1 둘째 줄 「표에서 찾은 값」을 숫자로 못 바꿔 안 됨. 안 쓰게 된 계산 1 · 표 조회 1 을 지울 길 없음 | 고정값 · 율 | 아니요 | 중 | 율 칸 둘을 따로 바꿔야 함을 스스로 알아야 함 | +| S2① | 조건 칸 「돌」 입력값 → 고정 「깬돌」 | 됨 | 4 (표 조회 2 「돌」 고르기 → 고정값 → 「깬돌」 글 · 표 조회 3 에 또) | ② 표 조회 | 같은 표를 석공 · 보통인부 두 번 조회 — 한쪽만 바꾸면 석공은 깬돌 · 보통인부는 깬잡석 품으로 섞여 계산됨(실측 121,863원 · 경고 없음). 예시값 뒷길이 25 에는 깬돌 줄이 없어 「—」. 원문 표 칸 역할 알약도 「고정」으로 바뀌지만 어느 조회 것인지 안 보임 | 표 조회 · 무관(조건에서 뺌) · 고정값 | 아니요(글 「깬돌」은 손으로 적음) | 중 | 고르기는 쉬우나 같은 조건이 두 곳에 있음 · 오타면 해당 없음 | +| S2② | 쓰는 표 바꾸기(메쌓기 → 찰쌓기 표) | 안 됨 | — | ② 표 조회 | 표 이름은 글자 조각 — 누를 곳 없음. 바꾸는 함수(`setLookupTable` · `M01_LB_Blocks.ts:627`)는 있으나 화면에 안 붙음 | — | — | 불가 | 화면에 길 없음 | +| S2③ | 결과 칸 바꾸기(석공 → 보통인부 칸) | 안 됨(꼼수 하나) | 원문 칸 알약 1 → 「결과 칸 바꾸기는 아직 안 됨」 | 원문 · ② 표 조회 | 「인 줄의 석공」은 글자 조각. 알약은 조건 → 고정 → 결과로 돌고, 결과 칸을 조건으로 돌리면 「「석공」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함」. 꼼수: 계산 2 바탕을 「표 조회 3 · 보통인부」로 고르면 석공 줄이 보통인부 품을 씀(고르기 목록에 있음 · 실측 안 함) | 결과 · 바탕 | 아니요 | 불가 | 표 조회의 결과 칸을 고르는 곳이 없음 | +| S3① | 새 입력값 「경사」(완 / 급) · 「급이면 석공 품 10% 더함」 | 안 됨 | — | ① 입력값 · ③ 계산 | 입력값 더하기 · 지우기 단추 없음(① 은 단위 · 범위만 고침 · 선택 목록 낱말도 못 고침). 계산 더하기 없음. 단계 안내(새로 만들기)에서만 숫자 입력값을 「입력값으로 받음」으로 만들 수 있음 — 선택(완/급) 꼴 아님 · 모든 결과 칸에 같이 걸림 | — | — | 불가 | 화면에 길 없음 | +| S3② | 품셈에 없는 작은 표 만들어 잇기 | 안 됨 | — | 원문 | 표 만들기 화면 없음(이 판 · 옛 M01 표 화면 모두). 원문 표 칸 고침은 미리 보기만 · 줄 더하기도 없음 | — | — | 불가 | 표는 품셈 원문에서만 옴 | +| S3③ | 새 항목 「잡재료 = 노무비의 2%」 | 일부 | 2 (「+ 비율」 · 2 입력 · 바탕 노무비는 기본) | ④ 항목 연결 | 2,437원 더해짐. 그러나 이름 칸 없음(일위대가표에 이름 빈 「1 식」) · 넣을 자리 고르기 없음 → 경비로 들어감(잡재료는 재료비여야). 단계 안내에서는 이름 · 넣을 자리 있음 | 비율 · 노무 줄의 합 | 아니요 | 중 | 덧붙임 한 갈래만 · 이름 · 자리 못 정함 | +| S4 | 관계 지우기(할증 · 표 조회 · 입력값) | 안 됨 | — | ② ③ ① | 할증 · 표 조회 · 입력값 · 계산 모두 지움 단추 없음(할증은 율 0 꼼수). 항목 「지움」은 1 누름 · 안 물음 — 보통인부를 지워도 그 줄이 쓰던 표 조회 3 · 계산 3 이 화면에 남고 알림 없음. 「실행 취소」 10번으로 표 칸 고침까지 다 되돌아옴(확인) · 로직 삭제는 쓰는 곳 검사 뒤 물음 | — | 아니요 | 불가 (항목만 상) | 관계를 지우는 길이 없음 | +| S5 | 관계 한눈에 보기 | 일부 | 0 (보기 바꿈 1) | 문장 · 격자 · 원문 | **문장 보기**: ① → ⑤ 차례 · 「표 조회 2 메쌓기(인력) 에서 뒷길이 이 입력값 · 뒷길이 … 인 줄의 석공」 → 「계산 2 석공 수량 표 조회 2 · 석공 에 계산 · 증가율 = 30 % 를 더 얹음」 → 「【노무】 석공 이 계산 2 = 0.195 인 = 52,437 원」 — 번호를 거꾸로 따라가면 길이 보이나 선 · 화살표 없음. **격자 보기**: 줄을 누르면 그 줄이 쓰는 줄 하나만 칠함(석공 → 계산 2 · 표 조회 · 입력값까지 안 이어짐) · 값 칸이 오른쪽에 잘려 고정값(30 · 깬돌)이 안 보임 · 표 이름 대신 QF000421. **원문**: 「이 표를 쓰는 로직 1 개」 · 주③ 「계산에 쓰임」 · 칸 역할 알약 | 표 조회 1 · 2 · 3 번호 · 바탕 · QF 번호 | 아니요 | 중 | 읽을 수는 있으나 한 장 그림이 아님 | +| S6 | 로직끼리 관계(작업량 로직 → 받는 로직) | 보기 일부 · 바꾸기 안 됨 | 0 | 산출근거 | 골재운반 모래(GF000134) 에 「계산 1 작업량 = 60 · 골재운반 덤프트럭 시간당 작업량」 글자만. 넘기는 값(qt=qt · f=f · E=E · Cm=Cm) 안 보임 · 눌러서 그 로직 열기 없음 · 고르기 목록에 「다른 로직」 없음. GF000133 쪽에 「이 로직을 쓰는 로직」 목록 없음(표에만 있음 · 실제는 GF000134 · 135 · 136 셋). 계산 2 문장이 「1 = 1 ÷ 작업량 = 60」으로 읽기 어색 | qt · f · E · Cm(기호 그대로) | 아니요 | 하 | 보이기만 하고 따라가기 · 바꾸기 길 없음 | + +## 안 되는 것이 왜 안 되나 (코드 근거) + +1. 블록 모형이 「있는 관계 고치기」 세 함수뿐 — `setCond`(조건 칸 출처) · `setLookupTable`(쓰는 표 · 화면에 안 붙음) · `setCalc`(계산 조각 · 틀) — `M01_LB_Blocks.ts:604-645`. 표 조회 · 계산 · 입력값을 **더하거나 지우는 함수가 없음**. 고침은 원래 식 글의 그 자리만 갈아끼우는 방식이라 새 자리를 만들 수 없음. +2. 화면 쪽도 같은 범위 — 문장 보기는 항목 카드만 더하기 · 지우기(`M01_LB_Steps.ts:172` `addCard` · `:249` `removeCard`) · 격자 보기는 `setCond` · `setCalc` 만(`M01_LB_Grid.ts:369-412`) · 계산 틀(할증 → 값 등)은 못 바꿈. +3. 값 고르기 목록 = 입력값 · 표 조회 · 다른 계산 · 고정값(숫자)만(`M01_LB_Steps_Cell.ts:235-275`). 「다른 로직」 · 「글 고정값」은 목록에 없어 로직 부름 줄은 글자 조각으로만 보임. +4. 표 조회의 결과 칸 바꾸기는 「아직 안 됨」으로 막아 둠(`M01_LB_Entry.ts:86` · `:437`). +5. 표 칸 고침은 정함 5대로 메모리 사본 · 저장 안 함 — 그래서 S1① 처럼 관계와 표 범위를 함께 바꿔야 하는 일은 저장 뒤 깨짐. +6. 새 입력값 · 새 표 · 조건 「급이면」 같은 품셈에 없는 관계는 단계 안내(새로 만들기)에만 일부(숫자 입력값 · 품셈 절의 다른 표로 할증 조건 목록) — 고치는 화면에는 길이 없음. +7. 「+ 비율」 카드에는 이름 · 넣을 자리 칸이 없음(단계 안내의 덧줄은 있음) — 같은 뜻의 두 길이 서로 다름. + +## 지금 화면(옛 단가산출)에서는 + +공통: 표준 로직은 못 고침 → 「본떠 만들기」로 자체 사본을 만든 뒤 편집기에서 식 글을 고침(34-1B 실측 + 코드). 거의 다 되지만 모두 **식을 써야 함 → 초등 하**. + +| # | 옛 화면 | 어떻게 | +| --- | --- | --- | +| S1① | 됨 · 식 | 중간 값 식 `만약(높이 <= 3, 0, …)` 의 3 → 2.5 (표 범위 문제는 같음) | +| S1② | 됨 · 식 | 보통인부 수량 식 끝 `* (1 + 증가율 / 100)` 지움 | +| S1③ | 됨 · 식 | 수량 식을 `… * 1.3` 으로 · 또는 중간 값 증가율 = 30 | +| S2① | 됨 · 식 | `찾기(QF000421, …, 돌=돌, …)` → `돌='깬돌'` (두 줄 다) | +| S2② | 됨 · 식 | 식 안 QF 번호 고침 · 또는 「표」 단추(수량 칸을 통째로 바꿈) | +| S2③ | 됨 · 식 | 식 끝 `.석공` → `.보통인부` | +| S3① | 됨 · 식 | 설계에서 받을 값 「줄 추가」(이름 경사 · 고르기 「완, 급」) + 수량 식에 `* 만약(경사 == '급', 1.1, 1)` | +| S3② | 안 됨 | 표 만들기 없음 · `만약` 식으로 흉내만 | +| S3③ | 됨 · 식 조금 | 덧줄 「줄 추가」 · 이름 잡재료 · 식 `노무비 * 0.02` · 비목 재료비 | +| S4 | 됨 · 안 물음 | 칸마다 × — 쓰는 곳 검사 없음 · 저장 때 엔진 검사(422)로만 걸림 | +| S5 | 일부 | 텍스트 수식(이름 식 · 값 식 두 칸) · 알약 누르면 표 창 | +| S6 | 됨 · 식 | 중간 값 `로직(GF000133, qt=qt, f=f, E=E, Cm=Cm)` 글 고침 · 알약 「로직」 누르면 그 로직을 엶 | + +## 덧붙여 본 것 + +- 계산이 멈추면 위 알림이 「서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요」 — 서버는 `ok:true` 와 줄마다 까닭(「표 QF000422(13-4-1)에 높이 2.7 인 줄이 없음」)을 줌. 원문 표 위의 「해당 없음 — 예시값에 맞는 줄이 없음」만 맞는 말. +- 칸 고침은 글을 넣고 칸을 떠나야(Enter · 다른 곳 누름) 반영됨. +- 고친 뒤 처음 값으로 다 되돌리면 「닫기」가 묻지 않고 닫힘(맞는 동작) · 「저장 안 함」 표시는 그대로 남아 있었음. +- 원문 칸 역할 알약은 조건 → 고정 → 결과로 돌기만 하고 무엇이 바뀌는지 미리 말하지 않음. diff --git a/docs/raw/M01_쉬운단가/시트로직_정함.md b/docs/raw/M01_쉬운단가/시트로직_정함.md new file mode 100644 index 000000000..a3f21d53f --- /dev/null +++ b/docs/raw/M01_쉬운단가/시트로직_정함.md @@ -0,0 +1,38 @@ +# 시트 로직 시험판 — 정함 (PLAN 35-5 · 브레인 · 10-01 오후) + +사용자 허락(그대로): 「일단 이해한대로 시험판을 만들어보자. 완전히 새로운 품셈을 사용자가 만들려면 기존에 있는 정보를 가져올수도 있지만 새로 만들기도 되어야하면 그럴려면 저장이 되어야할것같아. 일단은 시험판이니까 임시폴더 내부에 저장하고 이상하면 한번에 삭제하는거야.」 +제안서 = https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 근거 = 같은 폴더 `로직판_관계제어_점검.md`(지금 시험판은 관계 더하기 · 지우기 · 다시 잇기 · 새 표가 안 됨) · `로직판_엑셀로직_지도.md` 2 · 3절(우리 스프레드시트 엔진 · 서버 Node 다시 셈). + +## 목적 + +사용자가 코딩 · 브레인 없이, 품셈에 묶이지 않고 단가산출 로직을 새로 만들고 · 기존 정보(품셈 표 · 노임 · 자재 · 장비 · 기존 로직)를 가져오고 · 관계를 더하고 · 바꾸고 · 지움. 관계 = 진짜 엑셀 수식 · 입출력 칸 = 알약. + +## 정함 + +1. **로직 한 벌 = 통합문서 하나**(우리 스프레드시트 Workbook) · **상자 하나 = 시트 하나** · **알약 = 이름 붙은 칸**(입력 알약 · 출력 알약). +2. **선 = 입력 알약 칸에 다른 시트 칸 참조 수식을 저절로 씀**(`='표 · 메쌓기'!E7`) — 엔진이 시트 사이 참조를 이미 셈 · 선을 지우면 그 수식을 지움 · 순환은 엔진 `#CYCLE!` + 그을 때 막음. +3. 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과. 모든 상자 아래 판은 같은 시트 편집기 + 종류별 손잡이. +4. **바깥 값 넣기** — 품셈 표 줄 · 노임 · 자재 · 장비 단가 · 기존 로직 결과는 시트에 「바깥 값」 칸으로 넣음: 화면은 열 때 API 로 받아 넣고, 저장 때 서버가 마스터에서 다시 넣은 뒤 셈(브라우저 값 안 받아 적음). 품셈 표는 사본으로 들고 있지 않음(열 때 · 저장 때 마스터에서 새로). +5. **계산 = 우리 스프레드시트 엔진 한 벌** — 화면은 브라우저, 저장은 서버가 같은 TS 엔진을 Node 로(`common_util/common_util_spreadsheet.py` 길 · 계산 자리 ②). 지금 로직 1,469개(파이썬 엔진)는 안 건드림. +6. **저장 = 임시 폴더** `M01_MasterData/SheetLogic/_data/` (폴더 안 `.gitignore` 로 git 밖) · 키 = `SL-0001` 꼴(자체 번호 · `_키대장.json` 안 씀) · 사용자 표도 여기. 폴더 `SheetLogic/` 를 지우면 코드 · 자료가 한 번에 사라짐. +7. **폴더 밖** — 단추 한 곳(LogicLab.ts 가져오기 한 줄 + 부르기 한 줄 · 「로직 만들기(시험)」 옆 「시트 로직(시험)」) · 서버 등록 한 줄(main.py). 기존 서버 · 엔진 · 마스터 파일 · EasyCalc · LogicBuilder 파일은 안 고침(가져다 쓰기만). +8. **화면** — 위 가로 다이어그램 띠(입력값 → 표 → 계산 → 항목 → 결과 · 단계마다 세로 쌓기 · 자리는 저절로 · 상자 양옆에 입력 · 출력 알약 · 출력 알약을 끌어 입력 자리에 놓으면 선) · 아래 왼쪽 = 고른 상자의 시트 판 · 아래 오른쪽 = 결과(합계 · 노무비 · 재료비 · 경비 · 일위대가표 · 예시값). +9. **가져오기** — 기존 로직을 시트 로직으로 바꿔 가져옴(LogicBuilder Blocks 로 읽고 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · 표 찾기 → 표 상자 + XLOOKUP 또는 INDEX/MATCH) · 시험 로직 6개(GF000208 · GF000219 · GF000025 · GF000249 · GF000133 · GC000051)는 바꾼 값 = 지금 /calc 값이어야 함. +10. **용어** — 시트 로직 · 상자 · 선 · 입력 알약 · 출력 알약 · 입력값 · 고정값 · 표 · 계산 · 항목(노무 · 재료 · 장비 · 일위대가 · 비율) · 다른 로직 · 결과 · 예시값 · 품셈 표 / 내 표 · 노무비 · 재료비 · 경비. + +## 파일 · 창 (모두 `M01_MasterData/SheetLogic/` · 파일마다 700줄 안) + +| 파일 | 창 | 하는 일 | +| --- | --- | --- | +| `M01_SL_Model.ts` · `M01_SL_Eval.ts` | sub7 | 타입(먼저 올림) · 상자 더하기 / 지우기(시트 만들기 · 지우기) · 선 잇기 / 끊기(참조 수식 쓰기 · 지우기) · 알약 정하기 · 검사(순환 · 빈 입력) · 화면 계산(바깥 값 넣기 → 엔진 → 출력 알약 · 결과 읽기) | +| `M01_SL_Router.py` · `M01_SL_Api.ts` · `.gitignore` | sub4 | `_data` 목록 · 열기 · 저장 · 지우기 · 저장 때 바깥 값 다시 넣고 Node 로 셈 · 바깥 값 한 번에 받기(표 · 노임 · 자재 · 장비 · 기존 로직 결과) · 등록 한 줄 | +| `M01_SL_Diagram.ts` + css | sub8 | 위 가로 띠 · 상자 · 알약 · 선(끌어 잇기 · 고르기 · 지우기) · 「+」 상자 더하기 · 이어진 선 진하게 | +| `M01_SL_Panel.ts` + css | sub2 | 아래 시트 판(우리 스프레드시트 편집기 넣기) · 칸 → 입력 / 출력 알약 정하기 · 칸 위 알약 표시 · 틀 단추(할증 · 나누기 · 만약 · 표에서 찾기 — 진짜 수식을 넣어 줌) | +| `M01_SL_Table.ts` + css | sub3 | 표 상자 손잡이 — 품셈 표 고르기(절 · 이름 찾기) → 시트에 넣기 · 내 표(빈 표 · 엑셀 붙여넣기) · 조건 칸 / 결과 칸 알약 · 「표에서 찾기」 수식 돕기 | +| `M01_SL_Item.ts` · `M01_SL_Result.ts` + css | sub5 | 항목 상자 손잡이(노무 · 재료 · 장비 · 일위대가 고르기 → 단가 바깥 값 · 수량 알약 · 노무비 / 재료비 / 경비 출력) · 결과 칸(합계 · 비목 · 일위대가표 · 예시값) | +| `M01_SL_Import.ts` | sub6 | 기존 로직 → 시트 로직(정함 9) · 6개 같은 값 시험 | +| `M01_SL_Entry.ts` + css | sub1 | 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 세 칸 틀 · 저장 · 지우기 · 실행 취소 · 저장 안 함 물음 · 잇기 · 끝 검증 | + +## 끝 검증 (sub1 · 사용자처럼) + +관계 제어 점검 여섯이 모두 되어야 함 — S1 할증 문턱 · 석공에만 · 고정 30% · S2 조건을 고정값으로 · 쓰는 표 바꾸기 · 결과 칸 바꾸기 · S3 새 입력값 「경사」 + 「급이면 10%」 · 내 표 만들어 잇기 · 새 항목 · S4 관계 지우기 · S5 한눈에 보기 · S6 로직끼리 잇기 + 완전히 새 로직 하나(품셈 없이) 만들어 저장 → 다시 열기 → 지우기 · 기존 로직 6개 가져와 값 같음 · 저장 때 서버 값 = 화면 값. From eac31f872a65e95399798df48e176d7b5dfe9145 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:58:43 +0900 Subject: [PATCH 18/20] =?UTF-8?q?chore(M01):=20=EC=8B=9C=ED=8A=B8=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=95=ED=95=A8=20=C2=B7=20=EA=B4=80=EA=B3=84?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=A0=90=EA=B2=80=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=B5=EC=9C=A0=20docs=20=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EB=8B=B4=EC=9D=8C(=EC=9B=90=EA=B2=A9=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/M01_쉬운단가/로직판_관계제어_점검.md | 63 +++++++++++++++++++ docs/raw/M01_쉬운단가/시트로직_정함.md | 38 +++++++++++ 2 files changed, 101 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/로직판_관계제어_점검.md 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..94c353e78 --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_관계제어_점검.md @@ -0,0 +1,63 @@ +# 35-3 점검 — 로직 만들기(시험)에서 「표끼리의 관계」를 초등학생이 제어할 수 있나 (sub6 · 확인만) + +- 대상: 내 포트 8008 · 5181 → M01 → 단가산출 로직 → 「로직 만들기(시험)」. git-sync 로 dev 최신(105 커밋)을 받은 뒤 서버를 다시 띄움. 판정 직전 세션 SYSTEM_ADMIN 확인. +- 시험 로직: 돌쌓기 메쌓기(인력) — 표준 GF000219 은 읽기만이라, 이미 있는 현장 사본 GX000001(같은 식)을 열어 메모리에서만 고침. **저장 · 복사 편집 · 삭제는 안 누름**. 끝에 「실행 취소」 10번으로 모두 되돌린 것 확인. 서버 로직 수 그대로 · git 깨끗. 덤프트럭 시간당 작업량 GF000133 · 그것을 부르는 골재운반 모래 GF000134 는 읽기만. +- 사진: `tmp/로직판_관계_사진/` (r01 표준 열기 · r02 현장 사본 · r03 문턱만 바꿈 → 금액 멈춤 · r04 표 칸까지 고침 → 할증 걸림 · r05 격자 · r06 비율 더하기 · r07 격자 칠하기 · r08 로직 부름). +- 걸음 = 고칠 화면이 열린 뒤 누름 수(칸에 글 넣기 1 번 = 1). 들어가기는 M01 → 단가산출 로직 → 로직 만들기(시험) → 로직 고름 = 3 누름. 표준이면 「복사 편집」 1 더(누르는 순간 서버에 현장 사본이 써짐 · 저장 단추를 거치지 않음). + +## 한 줄 결론 + +**이미 있는 관계의 「값 칸」을 바꾸는 일(문턱 숫자 · 할증율 · 조건을 고정값으로)은 고르기 하나 · 숫자 하나로 되고 식은 안 씀. 그러나 관계 자체를 더하고 · 지우고 · 다른 표나 다른 결과 칸으로 잇는 일은 화면에 단추가 없어 거의 안 됨.** 품셈에 없는 관계(새 입력값 · 새 조건 · 새 표)는 만들 수 없고, 「+ 비율」 한 갈래만 있음. 초등학생 판정은 바꾸기 = 중, 더하기 · 지우기 · 다시 잇기 = 불가. + +## 시나리오 표 + +| # | 할 일 | 됨 | 걸음 | 지나간 화면 | 막힌 곳 | 모를 낱말 | 식을 쓰나 | 초등 | 까닭 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| S1① | 높이 할증 문턱 3m → 2.5m | 일부 | 3 (문턱 칸 1 + 원문 표 칸 「3~4」→「2.5~4」 2) | 산출근거 ③ 계산 · 원문 표 | 문턱만 바꾸면 2.5~3m 에서 증가율 표에 맞는 줄이 없어 금액이 「—」로 멈춤. 표 칸 고침은 「미리 보기 — 저장은 결정 뒤」라 저장되는 것은 문턱뿐. 멈출 때 위 알림은 「서버와 이어지지 않음」(실제 까닭은 「표 QF000422 에 높이 2.7 인 줄이 없음」) | 조건 목록 · 「위에서 처음 맞는 줄의 값을 씀」 · 그 외 · 해당 없음 | 아니요 | 하 | 칸은 쉬우나 문턱과 표 범위가 엮인 것이 안 보이고, 깨진 뒤 까닭이 엉뚱함 | +| S1② | 높이 할증을 석공에만 · 보통인부는 빼기 | 됨(꼼수) | 1 (계산 3 율 칸 「계산 · 증가율」→「고정값」 · 0 이 저절로) | ③ 계산 | 「할증 빼기」 단추는 없음 — 율 0% 로 둠. 계산 3 은 남음 | 바탕 · 율 · 「% 를 더 얹음」 · 계산 · 증가율 | 아니요 | 중 | 문장 「표 조회 3 · 보통인부 에 계산 · 증가율 = 30 % 를 더 얹음」을 읽어야 고를 칸을 찾음 | +| S1③ | 증가율 표 대신 고정 30% | 됨(일부) | 4 (계산 2 · 3 율 칸 → 고정값 → 30 · 두 번) | ③ 계산 | 높이와 상관없이 늘 30%(3m 아래도). 「3m 넘을 때만 30%」는 계산 1 둘째 줄 「표에서 찾은 값」을 숫자로 못 바꿔 안 됨. 안 쓰게 된 계산 1 · 표 조회 1 을 지울 길 없음 | 고정값 · 율 | 아니요 | 중 | 율 칸 둘을 따로 바꿔야 함을 스스로 알아야 함 | +| S2① | 조건 칸 「돌」 입력값 → 고정 「깬돌」 | 됨 | 4 (표 조회 2 「돌」 고르기 → 고정값 → 「깬돌」 글 · 표 조회 3 에 또) | ② 표 조회 | 같은 표를 석공 · 보통인부 두 번 조회 — 한쪽만 바꾸면 석공은 깬돌 · 보통인부는 깬잡석 품으로 섞여 계산됨(실측 121,863원 · 경고 없음). 예시값 뒷길이 25 에는 깬돌 줄이 없어 「—」. 원문 표 칸 역할 알약도 「고정」으로 바뀌지만 어느 조회 것인지 안 보임 | 표 조회 · 무관(조건에서 뺌) · 고정값 | 아니요(글 「깬돌」은 손으로 적음) | 중 | 고르기는 쉬우나 같은 조건이 두 곳에 있음 · 오타면 해당 없음 | +| S2② | 쓰는 표 바꾸기(메쌓기 → 찰쌓기 표) | 안 됨 | — | ② 표 조회 | 표 이름은 글자 조각 — 누를 곳 없음. 바꾸는 함수(`setLookupTable` · `M01_LB_Blocks.ts:627`)는 있으나 화면에 안 붙음 | — | — | 불가 | 화면에 길 없음 | +| S2③ | 결과 칸 바꾸기(석공 → 보통인부 칸) | 안 됨(꼼수 하나) | 원문 칸 알약 1 → 「결과 칸 바꾸기는 아직 안 됨」 | 원문 · ② 표 조회 | 「인 줄의 석공」은 글자 조각. 알약은 조건 → 고정 → 결과로 돌고, 결과 칸을 조건으로 돌리면 「「석공」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함」. 꼼수: 계산 2 바탕을 「표 조회 3 · 보통인부」로 고르면 석공 줄이 보통인부 품을 씀(고르기 목록에 있음 · 실측 안 함) | 결과 · 바탕 | 아니요 | 불가 | 표 조회의 결과 칸을 고르는 곳이 없음 | +| S3① | 새 입력값 「경사」(완 / 급) · 「급이면 석공 품 10% 더함」 | 안 됨 | — | ① 입력값 · ③ 계산 | 입력값 더하기 · 지우기 단추 없음(① 은 단위 · 범위만 고침 · 선택 목록 낱말도 못 고침). 계산 더하기 없음. 단계 안내(새로 만들기)에서만 숫자 입력값을 「입력값으로 받음」으로 만들 수 있음 — 선택(완/급) 꼴 아님 · 모든 결과 칸에 같이 걸림 | — | — | 불가 | 화면에 길 없음 | +| S3② | 품셈에 없는 작은 표 만들어 잇기 | 안 됨 | — | 원문 | 표 만들기 화면 없음(이 판 · 옛 M01 표 화면 모두). 원문 표 칸 고침은 미리 보기만 · 줄 더하기도 없음 | — | — | 불가 | 표는 품셈 원문에서만 옴 | +| S3③ | 새 항목 「잡재료 = 노무비의 2%」 | 일부 | 2 (「+ 비율」 · 2 입력 · 바탕 노무비는 기본) | ④ 항목 연결 | 2,437원 더해짐. 그러나 이름 칸 없음(일위대가표에 이름 빈 「1 식」) · 넣을 자리 고르기 없음 → 경비로 들어감(잡재료는 재료비여야). 단계 안내에서는 이름 · 넣을 자리 있음 | 비율 · 노무 줄의 합 | 아니요 | 중 | 덧붙임 한 갈래만 · 이름 · 자리 못 정함 | +| S4 | 관계 지우기(할증 · 표 조회 · 입력값) | 안 됨 | — | ② ③ ① | 할증 · 표 조회 · 입력값 · 계산 모두 지움 단추 없음(할증은 율 0 꼼수). 항목 「지움」은 1 누름 · 안 물음 — 보통인부를 지워도 그 줄이 쓰던 표 조회 3 · 계산 3 이 화면에 남고 알림 없음. 「실행 취소」 10번으로 표 칸 고침까지 다 되돌아옴(확인) · 로직 삭제는 쓰는 곳 검사 뒤 물음 | — | 아니요 | 불가 (항목만 상) | 관계를 지우는 길이 없음 | +| S5 | 관계 한눈에 보기 | 일부 | 0 (보기 바꿈 1) | 문장 · 격자 · 원문 | **문장 보기**: ① → ⑤ 차례 · 「표 조회 2 메쌓기(인력) 에서 뒷길이 이 입력값 · 뒷길이 … 인 줄의 석공」 → 「계산 2 석공 수량 표 조회 2 · 석공 에 계산 · 증가율 = 30 % 를 더 얹음」 → 「【노무】 석공 이 계산 2 = 0.195 인 = 52,437 원」 — 번호를 거꾸로 따라가면 길이 보이나 선 · 화살표 없음. **격자 보기**: 줄을 누르면 그 줄이 쓰는 줄 하나만 칠함(석공 → 계산 2 · 표 조회 · 입력값까지 안 이어짐) · 값 칸이 오른쪽에 잘려 고정값(30 · 깬돌)이 안 보임 · 표 이름 대신 QF000421. **원문**: 「이 표를 쓰는 로직 1 개」 · 주③ 「계산에 쓰임」 · 칸 역할 알약 | 표 조회 1 · 2 · 3 번호 · 바탕 · QF 번호 | 아니요 | 중 | 읽을 수는 있으나 한 장 그림이 아님 | +| S6 | 로직끼리 관계(작업량 로직 → 받는 로직) | 보기 일부 · 바꾸기 안 됨 | 0 | 산출근거 | 골재운반 모래(GF000134) 에 「계산 1 작업량 = 60 · 골재운반 덤프트럭 시간당 작업량」 글자만. 넘기는 값(qt=qt · f=f · E=E · Cm=Cm) 안 보임 · 눌러서 그 로직 열기 없음 · 고르기 목록에 「다른 로직」 없음. GF000133 쪽에 「이 로직을 쓰는 로직」 목록 없음(표에만 있음 · 실제는 GF000134 · 135 · 136 셋). 계산 2 문장이 「1 = 1 ÷ 작업량 = 60」으로 읽기 어색 | qt · f · E · Cm(기호 그대로) | 아니요 | 하 | 보이기만 하고 따라가기 · 바꾸기 길 없음 | + +## 안 되는 것이 왜 안 되나 (코드 근거) + +1. 블록 모형이 「있는 관계 고치기」 세 함수뿐 — `setCond`(조건 칸 출처) · `setLookupTable`(쓰는 표 · 화면에 안 붙음) · `setCalc`(계산 조각 · 틀) — `M01_LB_Blocks.ts:604-645`. 표 조회 · 계산 · 입력값을 **더하거나 지우는 함수가 없음**. 고침은 원래 식 글의 그 자리만 갈아끼우는 방식이라 새 자리를 만들 수 없음. +2. 화면 쪽도 같은 범위 — 문장 보기는 항목 카드만 더하기 · 지우기(`M01_LB_Steps.ts:172` `addCard` · `:249` `removeCard`) · 격자 보기는 `setCond` · `setCalc` 만(`M01_LB_Grid.ts:369-412`) · 계산 틀(할증 → 값 등)은 못 바꿈. +3. 값 고르기 목록 = 입력값 · 표 조회 · 다른 계산 · 고정값(숫자)만(`M01_LB_Steps_Cell.ts:235-275`). 「다른 로직」 · 「글 고정값」은 목록에 없어 로직 부름 줄은 글자 조각으로만 보임. +4. 표 조회의 결과 칸 바꾸기는 「아직 안 됨」으로 막아 둠(`M01_LB_Entry.ts:86` · `:437`). +5. 표 칸 고침은 정함 5대로 메모리 사본 · 저장 안 함 — 그래서 S1① 처럼 관계와 표 범위를 함께 바꿔야 하는 일은 저장 뒤 깨짐. +6. 새 입력값 · 새 표 · 조건 「급이면」 같은 품셈에 없는 관계는 단계 안내(새로 만들기)에만 일부(숫자 입력값 · 품셈 절의 다른 표로 할증 조건 목록) — 고치는 화면에는 길이 없음. +7. 「+ 비율」 카드에는 이름 · 넣을 자리 칸이 없음(단계 안내의 덧줄은 있음) — 같은 뜻의 두 길이 서로 다름. + +## 지금 화면(옛 단가산출)에서는 + +공통: 표준 로직은 못 고침 → 「본떠 만들기」로 자체 사본을 만든 뒤 편집기에서 식 글을 고침(34-1B 실측 + 코드). 거의 다 되지만 모두 **식을 써야 함 → 초등 하**. + +| # | 옛 화면 | 어떻게 | +| --- | --- | --- | +| S1① | 됨 · 식 | 중간 값 식 `만약(높이 <= 3, 0, …)` 의 3 → 2.5 (표 범위 문제는 같음) | +| S1② | 됨 · 식 | 보통인부 수량 식 끝 `* (1 + 증가율 / 100)` 지움 | +| S1③ | 됨 · 식 | 수량 식을 `… * 1.3` 으로 · 또는 중간 값 증가율 = 30 | +| S2① | 됨 · 식 | `찾기(QF000421, …, 돌=돌, …)` → `돌='깬돌'` (두 줄 다) | +| S2② | 됨 · 식 | 식 안 QF 번호 고침 · 또는 「표」 단추(수량 칸을 통째로 바꿈) | +| S2③ | 됨 · 식 | 식 끝 `.석공` → `.보통인부` | +| S3① | 됨 · 식 | 설계에서 받을 값 「줄 추가」(이름 경사 · 고르기 「완, 급」) + 수량 식에 `* 만약(경사 == '급', 1.1, 1)` | +| S3② | 안 됨 | 표 만들기 없음 · `만약` 식으로 흉내만 | +| S3③ | 됨 · 식 조금 | 덧줄 「줄 추가」 · 이름 잡재료 · 식 `노무비 * 0.02` · 비목 재료비 | +| S4 | 됨 · 안 물음 | 칸마다 × — 쓰는 곳 검사 없음 · 저장 때 엔진 검사(422)로만 걸림 | +| S5 | 일부 | 텍스트 수식(이름 식 · 값 식 두 칸) · 알약 누르면 표 창 | +| S6 | 됨 · 식 | 중간 값 `로직(GF000133, qt=qt, f=f, E=E, Cm=Cm)` 글 고침 · 알약 「로직」 누르면 그 로직을 엶 | + +## 덧붙여 본 것 + +- 계산이 멈추면 위 알림이 「서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요」 — 서버는 `ok:true` 와 줄마다 까닭(「표 QF000422(13-4-1)에 높이 2.7 인 줄이 없음」)을 줌. 원문 표 위의 「해당 없음 — 예시값에 맞는 줄이 없음」만 맞는 말. +- 칸 고침은 글을 넣고 칸을 떠나야(Enter · 다른 곳 누름) 반영됨. +- 고친 뒤 처음 값으로 다 되돌리면 「닫기」가 묻지 않고 닫힘(맞는 동작) · 「저장 안 함」 표시는 그대로 남아 있었음. +- 원문 칸 역할 알약은 조건 → 고정 → 결과로 돌기만 하고 무엇이 바뀌는지 미리 말하지 않음. diff --git a/docs/raw/M01_쉬운단가/시트로직_정함.md b/docs/raw/M01_쉬운단가/시트로직_정함.md new file mode 100644 index 000000000..a3f21d53f --- /dev/null +++ b/docs/raw/M01_쉬운단가/시트로직_정함.md @@ -0,0 +1,38 @@ +# 시트 로직 시험판 — 정함 (PLAN 35-5 · 브레인 · 10-01 오후) + +사용자 허락(그대로): 「일단 이해한대로 시험판을 만들어보자. 완전히 새로운 품셈을 사용자가 만들려면 기존에 있는 정보를 가져올수도 있지만 새로 만들기도 되어야하면 그럴려면 저장이 되어야할것같아. 일단은 시험판이니까 임시폴더 내부에 저장하고 이상하면 한번에 삭제하는거야.」 +제안서 = https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 근거 = 같은 폴더 `로직판_관계제어_점검.md`(지금 시험판은 관계 더하기 · 지우기 · 다시 잇기 · 새 표가 안 됨) · `로직판_엑셀로직_지도.md` 2 · 3절(우리 스프레드시트 엔진 · 서버 Node 다시 셈). + +## 목적 + +사용자가 코딩 · 브레인 없이, 품셈에 묶이지 않고 단가산출 로직을 새로 만들고 · 기존 정보(품셈 표 · 노임 · 자재 · 장비 · 기존 로직)를 가져오고 · 관계를 더하고 · 바꾸고 · 지움. 관계 = 진짜 엑셀 수식 · 입출력 칸 = 알약. + +## 정함 + +1. **로직 한 벌 = 통합문서 하나**(우리 스프레드시트 Workbook) · **상자 하나 = 시트 하나** · **알약 = 이름 붙은 칸**(입력 알약 · 출력 알약). +2. **선 = 입력 알약 칸에 다른 시트 칸 참조 수식을 저절로 씀**(`='표 · 메쌓기'!E7`) — 엔진이 시트 사이 참조를 이미 셈 · 선을 지우면 그 수식을 지움 · 순환은 엔진 `#CYCLE!` + 그을 때 막음. +3. 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과. 모든 상자 아래 판은 같은 시트 편집기 + 종류별 손잡이. +4. **바깥 값 넣기** — 품셈 표 줄 · 노임 · 자재 · 장비 단가 · 기존 로직 결과는 시트에 「바깥 값」 칸으로 넣음: 화면은 열 때 API 로 받아 넣고, 저장 때 서버가 마스터에서 다시 넣은 뒤 셈(브라우저 값 안 받아 적음). 품셈 표는 사본으로 들고 있지 않음(열 때 · 저장 때 마스터에서 새로). +5. **계산 = 우리 스프레드시트 엔진 한 벌** — 화면은 브라우저, 저장은 서버가 같은 TS 엔진을 Node 로(`common_util/common_util_spreadsheet.py` 길 · 계산 자리 ②). 지금 로직 1,469개(파이썬 엔진)는 안 건드림. +6. **저장 = 임시 폴더** `M01_MasterData/SheetLogic/_data/` (폴더 안 `.gitignore` 로 git 밖) · 키 = `SL-0001` 꼴(자체 번호 · `_키대장.json` 안 씀) · 사용자 표도 여기. 폴더 `SheetLogic/` 를 지우면 코드 · 자료가 한 번에 사라짐. +7. **폴더 밖** — 단추 한 곳(LogicLab.ts 가져오기 한 줄 + 부르기 한 줄 · 「로직 만들기(시험)」 옆 「시트 로직(시험)」) · 서버 등록 한 줄(main.py). 기존 서버 · 엔진 · 마스터 파일 · EasyCalc · LogicBuilder 파일은 안 고침(가져다 쓰기만). +8. **화면** — 위 가로 다이어그램 띠(입력값 → 표 → 계산 → 항목 → 결과 · 단계마다 세로 쌓기 · 자리는 저절로 · 상자 양옆에 입력 · 출력 알약 · 출력 알약을 끌어 입력 자리에 놓으면 선) · 아래 왼쪽 = 고른 상자의 시트 판 · 아래 오른쪽 = 결과(합계 · 노무비 · 재료비 · 경비 · 일위대가표 · 예시값). +9. **가져오기** — 기존 로직을 시트 로직으로 바꿔 가져옴(LogicBuilder Blocks 로 읽고 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · 표 찾기 → 표 상자 + XLOOKUP 또는 INDEX/MATCH) · 시험 로직 6개(GF000208 · GF000219 · GF000025 · GF000249 · GF000133 · GC000051)는 바꾼 값 = 지금 /calc 값이어야 함. +10. **용어** — 시트 로직 · 상자 · 선 · 입력 알약 · 출력 알약 · 입력값 · 고정값 · 표 · 계산 · 항목(노무 · 재료 · 장비 · 일위대가 · 비율) · 다른 로직 · 결과 · 예시값 · 품셈 표 / 내 표 · 노무비 · 재료비 · 경비. + +## 파일 · 창 (모두 `M01_MasterData/SheetLogic/` · 파일마다 700줄 안) + +| 파일 | 창 | 하는 일 | +| --- | --- | --- | +| `M01_SL_Model.ts` · `M01_SL_Eval.ts` | sub7 | 타입(먼저 올림) · 상자 더하기 / 지우기(시트 만들기 · 지우기) · 선 잇기 / 끊기(참조 수식 쓰기 · 지우기) · 알약 정하기 · 검사(순환 · 빈 입력) · 화면 계산(바깥 값 넣기 → 엔진 → 출력 알약 · 결과 읽기) | +| `M01_SL_Router.py` · `M01_SL_Api.ts` · `.gitignore` | sub4 | `_data` 목록 · 열기 · 저장 · 지우기 · 저장 때 바깥 값 다시 넣고 Node 로 셈 · 바깥 값 한 번에 받기(표 · 노임 · 자재 · 장비 · 기존 로직 결과) · 등록 한 줄 | +| `M01_SL_Diagram.ts` + css | sub8 | 위 가로 띠 · 상자 · 알약 · 선(끌어 잇기 · 고르기 · 지우기) · 「+」 상자 더하기 · 이어진 선 진하게 | +| `M01_SL_Panel.ts` + css | sub2 | 아래 시트 판(우리 스프레드시트 편집기 넣기) · 칸 → 입력 / 출력 알약 정하기 · 칸 위 알약 표시 · 틀 단추(할증 · 나누기 · 만약 · 표에서 찾기 — 진짜 수식을 넣어 줌) | +| `M01_SL_Table.ts` + css | sub3 | 표 상자 손잡이 — 품셈 표 고르기(절 · 이름 찾기) → 시트에 넣기 · 내 표(빈 표 · 엑셀 붙여넣기) · 조건 칸 / 결과 칸 알약 · 「표에서 찾기」 수식 돕기 | +| `M01_SL_Item.ts` · `M01_SL_Result.ts` + css | sub5 | 항목 상자 손잡이(노무 · 재료 · 장비 · 일위대가 고르기 → 단가 바깥 값 · 수량 알약 · 노무비 / 재료비 / 경비 출력) · 결과 칸(합계 · 비목 · 일위대가표 · 예시값) | +| `M01_SL_Import.ts` | sub6 | 기존 로직 → 시트 로직(정함 9) · 6개 같은 값 시험 | +| `M01_SL_Entry.ts` + css | sub1 | 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 세 칸 틀 · 저장 · 지우기 · 실행 취소 · 저장 안 함 물음 · 잇기 · 끝 검증 | + +## 끝 검증 (sub1 · 사용자처럼) + +관계 제어 점검 여섯이 모두 되어야 함 — S1 할증 문턱 · 석공에만 · 고정 30% · S2 조건을 고정값으로 · 쓰는 표 바꾸기 · 결과 칸 바꾸기 · S3 새 입력값 「경사」 + 「급이면 10%」 · 내 표 만들어 잇기 · 새 항목 · S4 관계 지우기 · S5 한눈에 보기 · S6 로직끼리 잇기 + 완전히 새 로직 하나(품셈 없이) 만들어 저장 → 다시 열기 → 지우기 · 기존 로직 6개 가져와 값 같음 · 저장 때 서버 값 = 화면 값. From ddaff8a6b8c98aab11ffcdfc51528b0d5b8975a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:59:19 +0900 Subject: [PATCH 19/20] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20Model=20=C2=B7=20Eval=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=C2=B7=20=ED=95=A8=EC=88=98=20=EB=A8=B8=EB=A6=AC=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SheetLogic · SlNode · SlPill · SlEdge · SlOutside · SlKind · SlEnd · SlUserTable · SlValue · SlOutsideValue · SlEvalResult newLogic · addNode · removeNode · connect · disconnect · setPill · removePill · outsideCells · applyOutside · evalLogic · checkLogic (속은 준비 중) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Eval.ts | 49 ++++++++ M01_MasterData/SheetLogic/M01_SL_Model.ts | 133 ++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Eval.ts create mode 100644 M01_MasterData/SheetLogic/M01_SL_Model.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts new file mode 100644 index 000000000..00dec78e7 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * M01_SL_Eval.ts + * 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사 + * 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음 + * ========================================================================== */ + +import type { SheetLogic, SlOutside } from "./M01_SL_Model"; + +/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ +export type SlValue = number | string | boolean | null; + +/** 바깥 값 하나 — 표는 칸 덩어리(cell 이 왼위) */ +export interface SlOutsideValue { + node: string; + cell: string; + value: SlValue | SlValue[][]; +} + +export interface SlEvalResult { + /** 상자 id → 출력 알약 이름 → 값 */ + pills: Record>; + result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null }; + /** 쉬운 말 */ + errors: string[]; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */ +export function outsideCells(_sl: SheetLogic): SlOutside[] { + return notReady(); +} + +/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */ +export function applyOutside(_sl: SheetLogic, _values: SlOutsideValue[]): SheetLogic { + return notReady(); +} + +/** 화면 계산 */ +export function evalLogic(_sl: SheetLogic): SlEvalResult { + return notReady(); +} + +/** 검사 — 빈 입력 알약 · 순환 · 오류 칸(쉬운 말 목록) */ +export function checkLogic(_sl: SheetLogic): string[] { + return notReady(); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts new file mode 100644 index 000000000..dc6d86f02 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -0,0 +1,133 @@ +/* ============================================================================= + * M01_SL_Model.ts + * 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸 + * 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식 · DOM 없음 + * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 + * ========================================================================== */ + +import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ +export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result"; + +/** 알약 = 이름 붙은 칸 */ +export interface SlPill { + name: string; + /** 「B3」 */ + cell: string; + unit?: string; + /** 바깥 값 칸 — 열 때 · 저장 때 마스터에서 넣음 */ + outside?: boolean; +} + +/** 상자 = 시트 하나 */ +export interface SlNode { + id: string; + kind: SlKind; + name: string; + sheetId: string; + pillsIn: SlPill[]; + pillsOut: SlPill[]; + /** 표 키 · 요소 · 기존 로직 키 · 내 표 id */ + ref?: string; + /** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */ + stage: number; +} + +/** 선 — 출력 알약 → 입력 알약 */ +export interface SlEdge { + id: string; + from: { node: string; pill: string }; + to: { node: string; pill: string }; +} + +/** 바깥 값 칸 */ +export interface SlOutside { + node: string; + cell: string; + kind: "table" | "price" | "logic"; + ref: string; +} + +/** 내 표 — 품셈에 없는 표(사용자가 만듦) */ +export interface SlUserTable { + id: string; + name: string; + cols: string[]; + rows: (number | string | null)[][]; +} + +export interface SheetLogic { + key: string | null; + name: string; + resultUnit: string; + workbook: Workbook; + nodes: SlNode[]; + edges: SlEdge[]; + userTables?: SlUserTable[]; +} + +/** 알약 한쪽 끝 */ +export interface SlEnd { + node: string; + pill: string; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +/** 빈 시트 로직 */ +export function newLogic(_name?: string, _resultUnit?: string): SheetLogic { + return notReady(); +} + +/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */ +export function addNode( + _sl: SheetLogic, + _kind: SlKind, + _name: string, +): { sl: SheetLogic; node: SlNode } { + return notReady(); +} + +/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */ +export function removeNode(_sl: SheetLogic, _node: string): SheetLogic { + return notReady(); +} + +/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */ +export function connect( + _sl: SheetLogic, + _from: SlEnd, + _to: SlEnd, +): { sl: SheetLogic; why: string | null } { + return notReady(); +} + +/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */ +export function disconnect(_sl: SheetLogic, _edge: string): SheetLogic { + return notReady(); +} + +/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈 */ +export function setPill( + _sl: SheetLogic, + _node: string, + _cell: string, + _dir: "in" | "out", + _name: string, + _extra?: Pick, +): SheetLogic { + return notReady(); +} + +/** 알약 빼기 — 이어진 선도 끊음 */ +export function removePill( + _sl: SheetLogic, + _node: string, + _dir: "in" | "out", + _name: string, +): SheetLogic { + return notReady(); +} From f5ba585f5f7c5cdef4898fd44bbfe6db4fe01dee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:59:34 +0900 Subject: [PATCH 20/20] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20Api=20=ED=83=80=EC=9E=85=20=C2=B7=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=A8=B8=EB=A6=AC=20=C2=B7=20=5Fdata=20?= =?UTF-8?q?=EB=A5=BC=20git=20=EB=B0=96=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SlOutside(표 · 노임 · 자재 · 장비 · 로직) · SlOutsideAt · SlOutsideValue · SlLogic · SlListItem · SlSaved - slList · slGet · slSave · slDelete · slOutside · slPlain 머리(몸은 다음 커밋) - SheetLogic/.gitignore — _data/ Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/SheetLogic/.gitignore | 1 + M01_MasterData/SheetLogic/M01_SL_Api.ts | 108 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 M01_MasterData/SheetLogic/.gitignore create mode 100644 M01_MasterData/SheetLogic/M01_SL_Api.ts diff --git a/M01_MasterData/SheetLogic/.gitignore b/M01_MasterData/SheetLogic/.gitignore new file mode 100644 index 000000000..c10c784e3 --- /dev/null +++ b/M01_MasterData/SheetLogic/.gitignore @@ -0,0 +1 @@ +_data/ diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts new file mode 100644 index 000000000..26b5b84df --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Api.ts @@ -0,0 +1,108 @@ +/* ============================================================================= + * M01_SL_Api.ts + * 시트 로직 Api — 목록 · 열기 · 저장(서버가 바깥 값을 다시 넣고 같은 엔진으로 다시 셈) · 지우기 · + * 바깥 값 한 번에 받기(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과) + * 서버 = `M01_SL_Router.py`(`/api/m01/sl/*`) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 + * ========================================================================== */ + +import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음) + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸) + * · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) + * · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값 + * · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */ +export type SlOutside = + | { id: string; kind: "표"; table: string } + | { id: string; kind: "노임"; key: string } + | { id: string; kind: "자재"; key: string } + | { + id: string; + kind: "장비"; + element: string; + inputs: Record; + field?: SlField; + } + | { + id: string; + kind: "로직"; + key: string; + inputs: Record; + field?: SlField; + }; +export type SlField = "노무비" | "재료비" | "경비" | "계"; + +/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ +export interface SlOutsideAt { + item: SlOutside; + sheet: string; + cell: string; +} + +/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ +export interface SlOutsideValue { + id: string; + ok: boolean; + value?: number | null; + rows?: (string | number | null)[][]; + name?: string; + unit?: string; + reason?: string; +} + +/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */ +export interface SlLogic { + /** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */ + key: string | null; + 이름: string; + 결과단위: string; + book: Workbook; + outside: SlOutsideAt[]; + /** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */ + [extra: string]: unknown; +} + +/** 목록 한 줄 */ +export interface SlListItem { + key: string; + 이름: string; + 결과단위: string; + /** 마지막 저장 때(ISO) */ + saved: string; +} + +/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */ +export interface SlSaved { + key: string; + logic: SlLogic; + values: SlOutsideValue[]; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +export async function slList(): Promise { + return notReady(); +} + +export async function slGet(_key: string): Promise { + return notReady(); +} + +export async function slSave(_logic: SlLogic): Promise { + return notReady(); +} + +export async function slDelete(_key: string): Promise { + return notReady(); +} + +export async function slOutside(_items: SlOutside[]): Promise { + return notReady(); +} + +/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */ +export function slPlain(_e: unknown): string { + return notReady(); +}