From 44ccd969bd126cf59d084188c435647796e1dbab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:42:07 +0900 Subject: [PATCH 1/3] auto: 2026-10-01 11:42 (ESD_LAPTOP) --- 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 a9efc452cc627f0948e7fb7579388333120f5f4e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:45:22 +0900 Subject: [PATCH 2/3] =?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=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B6=80=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01_LB_Calc.py — POST /api/m01/lb/calc {row, inputs, tables}: /calc 와 같은 셈 · 부를 때마다 새 사본에 표를 바꿔 끼움(전역 캐시 · 파일 안 건드림 · 저장 안 함) - GET /api/m01/lb/table-users — 표 「용도.로직키」 + 로직 글 대조 - main.py 등록 한 줄(시스템 관리자 · 폴더째 지우면 이 줄도 지움) - M01_LB_Api.ts — lbCalcLater(0.3초 · 최신만 · 쉬운 말) · tableUsers · fetchLbTable - 시험 test_m01_lb_calc.py — 견본 표 덮어 값 바뀜 · 캐시 · 파일 그대로 · 표 없으면 /calc 와 같음 · 404 · 사용처 · 403 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/LogicBuilder/M01_LB_Api.ts | 83 ++++++++-- M01_MasterData/LogicBuilder/M01_LB_Calc.py | 114 +++++++++++++ main.py | 5 + resources/tester/test_m01_lb_calc.py | 181 +++++++++++++++++++++ 4 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Calc.py create mode 100644 resources/tester/test_m01_lb_calc.py diff --git a/M01_MasterData/LogicBuilder/M01_LB_Api.ts b/M01_MasterData/LogicBuilder/M01_LB_Api.ts index 0095a909a..c48f7d03c 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Api.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Api.ts @@ -2,9 +2,14 @@ * M01_LB_Api.ts * 로직 만들기 판 Api — 고친 표로 예시값 계산(0.3초 · 최신만) · 표를 쓰는 로직(사용처) · 표 읽기 * 서버 = `M01_LB_Calc.py`(`/api/m01/lb/*`) · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` 「맞출 모양」 + * 시험: `resources/tester/test_m01_lb_calc.py` * ========================================================================== */ -import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchTable, fetchTables } from "../M01_MasterData_Api_Fetch"; +import { ApiError, type CalcAnswer, type LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; +import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; /** 표 키 → 표 사본(고친 값 포함 · 표 통째) — 서버가 메모리에서만 바꿔 끼움 */ export type LbTables = Record; @@ -23,24 +28,76 @@ export interface LbTable { table: Record & { 줄: Record[] }; } -const notReady = (): never => { - throw new Error("준비 중"); -}; +const CALC_WAIT_MS = 300; +const TABLE_GROUPS = ["소요량", "계수"]; // 표가 드는 그룹(계수 표가 소요량 파일에 든 것도 있음) + +async function send(path: string, body?: unknown): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01/lb${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new ApiError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +/** 로직이 받는 입력값만 — 모르는 이름을 보내면 서버가 멈춤 */ +function inputsFor(row: LogicRow, inputs: Record): Record { + return Object.fromEntries( + (row.입력 ?? []).filter((i) => i.이름 in inputs).map((i) => [i.이름, inputs[i.이름]]), + ); +} + +/** 계산 멈춤 까닭 — 표 키(`QF000421`)가 섞인 글은 뭉뚱그림 */ +function plainStop(reason: string): string { + const plain = safeReason(reason); + return /[A-Z]{2}\d{6}/.test(plain) ? safeReason("{}") : plain; +} + +let calcSeq = 0; +let calcTimer: ReturnType | undefined; /** 0.3초 뒤 고친 표로 서버 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ export function lbCalcLater( - _row: LogicRow, - _inputs: Record, - _tables: LbTables, - _onDone: (ans: CalcAnswer | null, plain: string | null) => void, + row: LogicRow, + inputs: Record, + tables: LbTables, + onDone: (ans: CalcAnswer | null, plain: string | null) => void, ): void { - notReady(); + const mine = ++calcSeq; + clearTimeout(calcTimer); + calcTimer = setTimeout(() => { + send("/calc", { row, inputs: inputsFor(row, inputs), tables }) + .then((ans) => { + if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(ans.reason)); + }) + .catch((e: unknown) => { + if (mine === calcSeq) onDone(null, plainError(e)); + }); + }, CALC_WAIT_MS); } -export async function tableUsers(_key: string): Promise { - return notReady(); +/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */ +export async function tableUsers(key: string): Promise { + const got = await send<{ users: LbTableUser[] }>( + `/table-users?${new URLSearchParams({ key }).toString()}`, + ); + return got.users; } -export async function fetchLbTable(_key: string): Promise { - return notReady(); +/** 표 키 하나로 표 통째 — 든 파일을 찾아(소요량 · 계수) Api_Fetch 의 표 읽기로 */ +export async function fetchLbTable(key: string): Promise { + for (const group of TABLE_GROUPS) { + const page = await fetchTables(group, "", "", key, 1, 5); + const head = page.tables.find((t) => t.키 === key); + if (head) return { file: head.file, ...(await fetchTable(head.file, key)) }; + } + throw new ApiError(404, `없는 표 「${key}」`); } diff --git a/M01_MasterData/LogicBuilder/M01_LB_Calc.py b/M01_MasterData/LogicBuilder/M01_LB_Calc.py new file mode 100644 index 000000000..471a562f3 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Calc.py @@ -0,0 +1,114 @@ +"""로직 만들기 판 시험 길(PLAN 35-2) — 고친 표로 시험 계산 · 표를 쓰는 로직. + + POST /api/m01/lb/calc {row, inputs, tables: {표 키: 표 통째}} → `/calc` 와 같은 답 + GET /api/m01/lb/table-users ?key=표 키 → {key, users: [{key, 이름, via}]} + +계산은 `/calc`(`M01_MasterData_Store.calc`)와 같은 길 — 부를 때마다 새로 읽은 마스터 사본 +(`cm.load`)에 로직 줄 · 표를 바꿔 끼워 셈. 전역 캐시 · 파일은 안 건드림(동시 요청에 안전) · +표 고침은 저장하지 않음(정함 5). 권한 = `/api/m01` 과 같음(등록 자리 `main.py` 한 줄 · +폴더째 지우면 그 줄도 지움). +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.M01_MasterData_Router import _num + +cm = store.cm +router = APIRouter(prefix="/api/m01/lb", tags=["M01 LogicBuilder (시험)"]) + + +class LbCalcBody(BaseModel): + row: dict[str, Any] + inputs: dict[str, Any] = {} + tables: dict[str, dict[str, Any]] = {} + + +def _put_tables(files: dict[str, dict], tables: dict[str, dict]) -> None: + """표 키 → 고친 표 — 그 표가 든 파일 사본 자리에 바꿔 끼움 · 키는 못 바꿈 · 없는 표는 404.""" + for key, table in tables.items(): + for data in files.values(): + if data.get("그룹") not in cm.mf.TABLE_GROUPS: + continue + items = data.get("표") or [] + at = next((i for i, t in enumerate(items) if str(t.get("키")) == key), None) + if at is not None: + items[at] = {**store._dec(table), "키": key} + break + else: + raise store.StoreError(404, f"없는 표 「{key}」") + + +def calc(row: dict, inputs: dict, tables: dict[str, dict]) -> dict: + """`Store.calc` 와 같은 셈 + 표 바꿔 끼우기 — 답 모양도 같음.""" + given = {k: store._dec(v) for k, v in inputs.items()} + files = cm.load(folder=store.FOLDER) # 부를 때마다 새 사본 — 캐시 아님 + _put_tables(files, tables) + key = store._swap(files, str(row.get("키") or ""), row, None) + try: + given = cm.mf.fill_empty(cm.mf.Master(files).logic(key), given) + result = cm.calc(files, key, given) + except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + return {"ok": False, "reason": str(e) or type(e).__name__} + if "결과" in result: + return {"ok": True, "result": result["결과"], "middle": result["중간"]} + return { + "ok": True, + "lines": result["줄"], + "sums": {k: result[k] for k in cm.mf.BUNDLE}, + "middle": result["중간"], + } + + +def table_users(key: str) -> dict: + """표를 쓰는 로직 — 표 「용도.로직키」 + 로직 글에 표 키가 든 것(읽기 전용 캐시에서).""" + files, _ = store.loaded() + table = next( + ( + t + for data in files.values() + if data.get("그룹") in cm.mf.TABLE_GROUPS + for t in data.get("표") or [] + if str(t.get("키")) == key + ), + None, + ) + if table is None: + raise store.StoreError(404, f"없는 표 「{key}」") + listed = {str(k) for k in (table.get("용도") or {}).get("로직키") or []} + named = re.compile(rf"(? dict: + return _call(calc, body.row, body.inputs, body.tables) + + +@router.get("/table-users") +def get_table_users(key: str) -> dict: + return _call(table_users, key) diff --git a/main.py b/main.py index 6813531f4..911eade9d 100644 --- a/main.py +++ b/main.py @@ -638,6 +638,11 @@ app.include_router(b07_frame_router, dependencies=protected_with_company) # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] app.include_router(m01_master_data_router, dependencies=system_admin_only) +# 로직 만들기 시험 길 — 폴더째 지우면 이 줄도 지움 +app.include_router( + __import__("M01_MasterData.LogicBuilder.M01_LB_Calc", fromlist=["router"]).router, + dependencies=system_admin_only, +) # 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서). app.include_router(m02_master_template_router, dependencies=system_admin_only) app.include_router(m02_layers_router, dependencies=protected) diff --git a/resources/tester/test_m01_lb_calc.py b/resources/tester/test_m01_lb_calc.py new file mode 100644 index 000000000..120101ae8 --- /dev/null +++ b/resources/tester/test_m01_lb_calc.py @@ -0,0 +1,181 @@ +"""로직 만들기 판 시험 길(`M01_MasterData/LogicBuilder/M01_LB_Calc.py`) 시험 — PLAN 35-2. + +· 고친 표 사본으로 셈하면 값이 바뀌고 · 전역 캐시 · 파일은 그대로 +· 표 없이 부르면 `/calc` 와 같은 값 · 없는 표는 404 +· 사용처 — 표 「용도.로직키」 + 로직 글 대조 +· 권한 — 시스템 관리자 아니면 403 +견본 표 · 로직 줄은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 마스터 값에 안 기댐). +""" + +from __future__ import annotations + +import copy +import json +import shutil +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import require_system_admin, verify_session +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + +REAL = store.FOLDER +OWN = "로직_자체.json" +SAMPLE = "QF999999" # 견본 표 — 사본 폴더에만 있음 +TABLE = { + "키": SAMPLE, + "원문번호": "", + "구분": "", + "상세구분": "", + "이름": "견본 쌓기 표", + "기준": "1㎡", + "출처": "자체", + "용도": {"공종": "견본", "대상": ["인력"], "로직키": []}, + "조건": {"돌": "고르기"}, + "값칸": {"석공": "인"}, + "줄": [{"돌": "깬돌", "석공": 0.5}, {"돌": "견치돌", "석공": 0.7}], + "주": [], +} + + +def _labor() -> tuple[str, Decimal]: + rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] + row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float))) + return row["키"], Decimal(str(row["값"])) + + +def _row(labor: str) -> dict: + """견본 로직 — 입력 「돌」 로 견본 표에서 석공 품을 찾아 노임에 곱함.""" + return { + "키": "", + "원문번호": "", + "이름": "견본 쌓기", + "결과단위": "원/㎡", + "출처": "자체", + "입력": [{"이름": "돌", "고르기": ["깬돌", "견치돌"]}], + "중간": [], + "호표": [ + { + "종류": "인력", + "요소": labor, + "이름": "석공", + "단위": "인", + "수량": f"찾기({SAMPLE}, 돌=돌).석공", + "비목": "노무비", + } + ], + "덧줄": [], + "끝수": None, + } + + +@pytest.fixture +def folder(tmp_path: Path, monkeypatch) -> Path: + for path in REAL.glob("*.json"): + if path.name == OWN: + continue + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + host = sorted(tmp_path.glob("소요량_*.json"))[0] # 견본 표를 사본 소요량 파일 하나에 더함 + data = json.loads(host.read_text(encoding="utf-8")) + data["표"].append(TABLE) + host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + monkeypatch.setattr(store, "FOLDER", tmp_path) + return tmp_path + + +@pytest.fixture +def client(folder: Path) -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + app.include_router(lb.router) + return TestClient(app) + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def test_고친_표로_값이_바뀌고_캐시와_파일은_그대로(client: TestClient, folder: Path) -> None: + labor, price = _labor() + row = _row(labor) + before = {p.name: p.read_bytes() for p in folder.glob("*.json")} + + plain = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}}) + assert plain["ok"] is True + assert Decimal(str(plain["sums"]["계"])) == Decimal("0.5") * price + + edited = copy.deepcopy(TABLE) + edited["줄"][0]["석공"] = 0.8 # 깬돌 줄 품 0.5 → 0.8 + body = {"row": row, "inputs": {"돌": "깬돌"}, "tables": {SAMPLE: edited}} + changed = _post(client, "/api/m01/lb/calc", body) + assert Decimal(str(changed["sums"]["계"])) == Decimal("0.8") * price + assert changed["lines"][0]["수량"] == 0.8 + + # 전역 캐시 · 파일 · 같은 요청 뒤 표 없는 셈은 그대로 + files = store.loaded()[0].values() + cached = next( + t for d in files if d.get("그룹") == "소요량" for t in d["표"] if t["키"] == SAMPLE + ) + assert cached["줄"][0]["석공"] == Decimal("0.5") + assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before + again = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}}) + assert again == plain + + # 키는 못 바꿈 — 사본 표의 「키」 가 달라도 그 자리 키로 셈 + _post(client, "/api/m01/lb/calc", {**body, "tables": {SAMPLE: {**edited, "키": "QF000001"}}}) + + +def test_표_없이_부르면_calc_와_같고_없는_표는_404(client: TestClient) -> None: + labor, _ = _labor() + row = _row(labor) + for stone in ("깬돌", "견치돌"): + mine = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": stone}}) + theirs = _post(client, "/api/m01/calc", {"key": "", "inputs": {"돌": stone}, "row": row}) + assert mine == theirs + + stop = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "호박돌"}}) + assert stop["ok"] is False and stop == _post( + client, "/api/m01/calc", {"key": "", "inputs": {"돌": "호박돌"}, "row": row} + ) + _post(client, "/api/m01/lb/calc", {"row": row, "tables": {"QF999998": TABLE}}, want=404) + + +def test_사용처_용도와_로직_글(client: TestClient, folder: Path) -> None: + labor, _ = _labor() + made = _post(client, "/api/m01/logic/new", {"logic": _row(labor)}) + first = next( + r["키"] for p in sorted(folder.glob("로직_*.json")) if p.name != OWN + for r in json.loads(p.read_text(encoding="utf-8"))["줄"] + ) # fmt: skip + host = sorted(folder.glob("소요량_*.json"))[0] + data = json.loads(host.read_text(encoding="utf-8")) + next(t for t in data["표"] if t["키"] == SAMPLE)["용도"]["로직키"] = [first, made["key"]] + host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + store.cache.stale([host.name]) + + res = client.get("/api/m01/lb/table-users", params={"key": SAMPLE}) + assert res.status_code == 200, res.text + users = {u["key"]: u["via"] for u in res.json()["users"]} + assert users == {first: "용도", made["key"]: "둘"} + assert client.get("/api/m01/lb/table-users", params={"key": "QF999998"}).status_code == 404 + + +def test_시스템_관리자가_아니면_403(folder: Path) -> None: + app = FastAPI() + app.include_router( + lb.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] + ) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} + client = TestClient(app) + assert client.post("/api/m01/lb/calc", json={"row": {}}).status_code == 403 + assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 403 + app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} + assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 200 From c22eaeb16ca148b8349890c2bb86a915a69d2110 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:46:28 +0900 Subject: [PATCH 3/3] =?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=20Blocks=20=EC=86=8D=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readLogic: 식 자리(중간 · 수량 · 요소 · 비율 · 결과)를 엔진 문법으로 읽어 ① 입력값 ② 표 조회(Table readFinds) ③ 계산(값 · 곱나눔 · 할증 · 더하기 · 조건목록 · 최소최대 · 올림 · 단계 · Cond readCond) ④ 항목(Model EasyDoc) ⑤ 결과값 - writeLogic = Model toRow · setCond · setLookupTable · setCalc 는 그 자리 글만 갈아끼운 뒤 다시 읽음 · newLogic - 못 읽는 꼴은 blocked 에 쉬운 말(표 조회 조건에 계산 · 그리고/또는 · 넘길 값에 계산) - 마스터 1,469 왕복 다른 것 0 · 블록으로 다 읽힘 1,396(95%) - 시험 resources/tester/test_m01_lb_blocks.py 14개(견본 여섯 · Node 고리로 EasyCalc 그대로 부름) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/LogicBuilder/M01_LB_Blocks.ts | 595 ++++++++++++++++++- resources/tester/test_m01_lb_blocks.py | 309 ++++++++++ 2 files changed, 879 insertions(+), 25 deletions(-) create mode 100644 resources/tester/test_m01_lb_blocks.py diff --git a/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts b/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts index 863ea1917..700d95767 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts @@ -2,11 +2,18 @@ * M01_LB_Blocks.ts * 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기 * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로 + * + * 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나 + * 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어 + * ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서 + * 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로) * ========================================================================== */ +import { currentLanguageIndex } from "@ui/ui_template_locale"; import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import type { CondRead } from "../EasyCalc/M01_EasyCalc_Cond"; -import type { EasyDoc } from "../EasyCalc/M01_EasyCalc_Model"; +import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond"; +import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model"; +import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table"; /** 값이 오는 곳 */ export type LbRef = @@ -47,7 +54,7 @@ export interface LbCalc { /** 곱나눔 · 더하기 · 할증(바탕 · 율) */ parts: (LbRef | LbExpr)[]; ops?: ("*" | "/" | "+" | "-")[]; - /** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 */ + /** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */ cond?: CondRead; at: { part: Exclude; index: number }; /** 원래 글(안 고치면 그대로 씀) */ @@ -77,44 +84,582 @@ export interface TableEdit { value: number | string | [number | null, number | null]; } -const notReady = (): never => { - throw new Error("준비 중"); +const TEXT = { + BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"], + CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"], + AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"], + PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"], + Unknown: ["모르는 이름이 있음", "Has an unknown name"], + Result: ["결과값", "Result value"], + Qty: ["수량", "quantity"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */ + +type Node = { + t: + "num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin"; + v?: string; + f?: string; + op?: string; + a?: Node; + b?: Node; + items?: Node[]; + head?: string; + args?: [string, Node][]; + src: string; }; +const CMPS = ["<=", ">=", "==", "!=", "<", ">"]; +const ROUND = ["올림", "버림", "반올림"]; + +function parse(s: string): Node | null { + let i = 0; + const fail = (): never => { + throw new Error("parse"); + }; + const ws = (): void => { + while (i < s.length && /\s/.test(s[i])) i++; + }; + const take = (w: string): boolean => { + ws(); + if (!s.startsWith(w, i)) return false; + i += w.length; + return true; + }; + const need = (w: string): void => void (take(w) || fail()); + const stick = (re: RegExp): string | null => { + re.lastIndex = i; + const m = re.exec(s); + if (m) i = re.lastIndex; + return m ? m[0] : null; + }; + const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail()); + const quoted = (): string => { + const e = s.indexOf("'", i); + if (e < 0) fail(); + const v = s.slice(i, e); + i = e + 1; + return v; + }; + /** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */ + const ref = (head: boolean): string => { + const st = i; + for (let d = 0; i < s.length; i++) { + const c = s[i]; + if ("({".includes(c)) d++; + else if (")}".includes(c)) { + if (!d) break; + d--; + } else if (!d && c === ",") break; + else if (head) continue; + else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break; + else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break; + } + return s.slice(st, i).trim(); + }; + const mk = (st: number, o: Omit): Node => ({ ...o, src: s.slice(st, i).trim() }); + const fields = (st: number, n: Node): Node => { + while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() }); + return n; + }; + const args = (): [string, Node][] => { + const out: [string, Node][] = []; + while (take(",")) { + const k = take("'") ? quoted() : word(); + need("="); + out.push([k, expr()]); + } + need(")"); + return out; + }; + const primary = (): Node => { + ws(); + const st = i; + if (take("(")) { + const a = expr(); + need(")"); + return fields(st, mk(st, { t: "paren", a })); + } + if (take("'")) return mk(st, { t: "str", v: quoted() }); + const n = stick(/\d+(?:\.\d+)?/y); + if (n) return mk(st, { t: "num", v: n }); + const w = word(); + if (w === "찾기" || w === "로직") { + need("("); + ws(); + const head = ref(true); + return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() })); + } + if (["만약", "최소", "최대", ...ROUND].includes(w)) { + need("("); + const items = [expr()]; + while (take(",")) items.push(expr()); + need(")"); + return fields(st, mk(st, { t: "call", f: w, items })); + } + if (s[i] === ":") { + i++; + ref(false); + return fields(st, mk(st, { t: "eref" })); + } + return fields(st, mk(st, { t: "name", v: w })); + }; + const unary = (): Node => { + ws(); + const st = i; + return take("-") ? mk(st, { t: "neg", a: unary() }) : primary(); + }; + const chain = (next: () => Node, ops: string[], once = false): Node => { + ws(); + const st = i; + let n = next(); + for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) + n = mk(st, { t: "bin", op, a: n, b: next() }); + return n; + }; + const mul = (): Node => chain(unary, ["*", "/"]); + const add = (): Node => chain(mul, ["+", "-"]); + const cmp = (): Node => chain(add, CMPS, true); + const both = (): Node => chain(cmp, ["그리고"]); + const expr = (): Node => chain(both, ["또는"]); + try { + const node = expr(); + ws(); + return i === s.length ? node : null; + } catch { + return null; + } +} + +const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n); +const flat = (s: string): string => s.replace(/\s+/g, ""); +const KEY = /^[A-Z]{2}\d{6}$/; + +/* ── 읽기 ───────────────────────────────────────────────────────────────── */ + +interface Ctx { + row: LogicRow; + inputs: Set; + mids: Set; + /** 찾기 글(빈칸 뺌) → 표 조회 번호 */ + finds: Map; + /** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */ + personSum: string | null; + bad: Set; +} + +/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */ +const SRC = new WeakMap(); +const keep = (ref: T, text: string): T => { + SRC.set(ref, { text, snap: JSON.stringify(ref) }); + return ref; +}; + +const isSimple = (n: Node, c: Ctx): boolean => { + const u = un(n); + if (u.t === "num" || u.t === "str") return true; + if (u.t === "neg") return un(u.a!).t === "num"; + return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!)); +}; + +/** 식 나무를 훑어 못 읽는 까닭을 모음 */ +function check(n: Node, c: Ctx): void { + const u = un(n); + if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr")); + if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup")); + if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc")); + if (u.t === "find" || u.t === "logic") return; + if (u.t === "name") { + const v = u.v!; + const known = + c.inputs.has(v) || + c.mids.has(v) || + KEY.test(v) || + ["노무비", "재료비", "경비", "계", "줄"].includes(v); + const item = (c.row.호표 ?? []).some((h) => h.이름 === v); + if (!known && !item) c.bad.add(tl("Unknown")); + } + for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c); +} + +/** 잎 하나 → 값이 오는 곳 */ +function refOf(node: Node, c: Ctx): LbRef | LbExpr { + const u = un(node); + const text = node.src; + if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text); + if (u.t === "neg" && un(u.a!).t === "num") + return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text); + if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text); + if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text); + if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text); + if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!)) + return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text); + if (c.personSum !== null && flat(text) === c.personSum) + return keep({ k: "cost", name: "사람줄합" }, text); + if (u.t === "field" && u.a!.t === "find") { + const id = c.finds.get(flat(u.src)); + if (id) return keep({ k: "lookup", id }, text); + } + if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) { + const call = u.t === "logic" ? u : u.a!; + return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text); + } + return { k: "expr", text }; +} + +const isLeaf = (n: Node): boolean => { + const u = un(n); + return ( + ["num", "str", "name", "field", "logic", "eref"].includes(u.t) || + (u.t === "neg" && un(u.a!).t === "num") + ); +}; + +/** 같은 연산 사슬 — a op b op c … */ +function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } { + const u = un(n); + if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] }; + const left = chainOf(u.a!, ops); + return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] }; +} + +function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc { + const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] }; + const node = parse(text); + if (!node) { + c.bad.add(tl("BadText")); + return { ...base, kind: "단계" }; + } + check(node, c); + const u = un(node); + const inner = (): CondRead | undefined => { + const r = readCond(text, c.row); + return r && r.via === null ? r : undefined; + }; + if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] }; + if (u.t === "call") { + const kind: LbCalcKind = + u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대"; + const cond = inner(); + const whole = + cond && + cond.blocks.length === 1 && + cond.blocks[0].p[0] === 0 && + cond.blocks[0].p[1] >= cond.text.length; + return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond }; + } + if (u.t === "bin" && u.op === "*") { + const r = un(u.b!); + if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") { + const rate = un(r.b!); + if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100") + return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() }; + } + } + for (const [kind, ops] of [ + ["곱나눔", ["*", "/"]], + ["더하기", ["+", "-"]], + ] as const) { + const ch = chainOf(u === node ? node : u, [...ops]); + if (ch.items.length > 1) + return { + ...base, + kind, + parts: ch.items.map((x) => refOf(x, c)), + ops: ch.ops as LbCalc["ops"], + cond: inner(), + }; + } + return { ...base, kind: "단계", cond: inner() }; +} + +/** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */ +function personSumText(row: LogicRow): string | null { + const terms = (row.호표 ?? []) + .filter((h) => h.종류 === "인력") + .map((h) => { + const q = String(h.수량 ?? "").trim(); + return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`; + }); + return terms.length ? `(${terms.join(" + ")})` : null; +} + +/** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */ +function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] { + const ho = row.호표 ?? []; + return [ + ...(row.중간 ?? []).map((m, i) => ({ + part: "중간" as const, + index: i, + text: m.식, + label: m.이름, + })), + ...ho.map((h, i) => ({ + part: "수량" as const, + index: i, + text: String(h.수량 ?? ""), + label: `${h.이름 ?? ""} ${tl("Qty")}`, + })), + ...ho + .map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" })) + .filter( + (p): p is { part: "요소"; index: number; text: string; label: string } => + typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text), + ), + ...(row.덧줄 ?? []).map((d, i) => ({ + part: "비율" as const, + index: i, + text: d.식, + label: d.이름, + })), + ...(row.결과 + ? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }] + : []), + ]; +} + /** 로직 줄 → 블록 */ export function readLogic( - _row: LogicRow, - _meta: { key: string | null; version: string | null; isBase: boolean }, + row: LogicRow, + meta: { key: string | null; version: string | null; isBase: boolean }, ): LbLogic { - return notReady(); + const doc = toDoc(row, meta); + const ps = personSumText(row); + const c: Ctx = { + row, + inputs: new Set((row.입력 ?? []).map((x) => x.이름)), + mids: new Set((row.중간 ?? []).map((x) => x.이름)), + finds: new Map(), + personSum: ps === null ? null : flat(ps), + bad: new Set(), + }; + const lookups: LbLookup[] = []; + const blocked: string[] = []; + const places = placesOf(row); + for (const p of places) { + const calls = readFinds(p.text); + if (!calls) { + blocked.push(`${p.label} — ${tl("BadText")}`); + continue; + } + for (const call of calls) { + const key = flat(p.text.slice(call.start, call.end)); + let id = c.finds.get(key); + if (!id) { + id = `L${lookups.length + 1}`; + c.finds.set(key, id); + const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] }; + look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) })); + ARGS.set(look, call.args); + lookups.push(look); + } + lookups + .find((x) => x.id === id)! + .at.push({ part: p.part, index: p.index, start: call.start, end: call.end }); + } + } + const calcs: LbCalc[] = []; + for (const p of places) { + if (p.part === "요소") { + const n = parse(p.text); + c.bad.clear(); + if (n) check(n, c); + else c.bad.add(tl("BadText")); + for (const b of c.bad) blocked.push(`${p.label} — ${b}`); + continue; + } + c.bad.clear(); + const name = p.part === "중간" ? p.label : null; + calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c)); + for (const b of c.bad) blocked.push(`${p.label} — ${b}`); + } + const resultCalc = calcs.find((x) => x.at.part === "결과"); + return { + doc, + inputs: row.입력 ?? [], + lookups, + calcs, + result: resultCalc + ? { kind: "값", unit: row.결과단위, calc: resultCalc } + : { kind: "금액", unit: row.결과단위 }, + blocked: [...new Set(blocked)], + }; } -/** 블록 → 로직 줄 — 고친 조각 자리만 글 갈아끼우기 */ -export function writeLogic(_lb: LbLogic): LogicRow { - return notReady(); +/** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */ +const ARGS = new WeakMap(); + +function argRef(a: FindArg, c: Ctx): LbRef | LbExpr { + const n = parse(a.rhs); + return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs }; +} + +/* ── 쓰기 ───────────────────────────────────────────────────────────────── */ + +/** 블록 → 로직 줄 */ +export function writeLogic(lb: LbLogic): LogicRow { + return toRow(lb.doc); } /** 단계 안내가 채울 빈 로직 */ -export function newLogic(_name?: string, _per?: string): LbLogic { - return notReady(); +export function newLogic(name = "", per = "㎡"): LbLogic { + return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false }); +} + +const metaOf = (lb: LbLogic) => ({ + key: lb.doc.key, + version: lb.doc.version, + isBase: lb.doc.isBase, +}); + +/** 한 자리 글 읽기 · 쓰기 */ +function textAt(row: LogicRow, part: LbPart, index: number): string { + if (part === "중간") return row.중간[index].식; + if (part === "비율") return row.덧줄![index].식; + if (part === "결과") return row.결과!.식; + const h = row.호표![index]; + return String(part === "수량" ? h.수량 : h.요소); +} + +function putText(row: LogicRow, part: LbPart, index: number, text: string): void { + if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text }; + else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text }; + else if (part === "결과") row.결과 = { ...row.결과!, 식: text }; + else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text }; + else row.호표![index] = { ...row.호표![index], 요소: text }; +} + +/** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */ +function splice( + lb: LbLogic, + edits: { part: LbPart; index: number; start: number; end: number; text: string }[], +): LbLogic { + const row = writeLogic(lb); + const groups = new Map(); + for (const e of edits) + groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]); + for (const list of groups.values()) { + const { part, index } = list[0]; + let text = textAt(row, part, index); + for (const e of [...list].sort((x, y) => y.start - x.start)) + text = text.slice(0, e.start) + e.text + text.slice(e.end); + putText(row, part, index, text); + } + return readLogic(row, metaOf(lb)); +} + +const numText = (n: number): string => { + const t = String(Number(n.toPrecision(12))); + return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; +}; + +/** 조각 → 글 — 안 바뀐 조각은 원래 글 */ +function refText(ref: LbRef | LbExpr, lb: LbLogic): string { + const src = SRC.get(ref); + if (src && src.snap === JSON.stringify(ref)) return src.text; + switch (ref.k) { + case "expr": + return ref.text; + case "input": + case "calc": + return ref.name; + case "fixed": + return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`; + case "cost": + return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name; + case "logic": + return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`; + case "lookup": { + const look = lb.lookups.find((x) => x.id === ref.id); + return look ? findText(look, look.conds, look.table) : "0"; + } + } +} + +/** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */ +function wrap(text: string, strong: boolean): string { + const n = parse(text); + const low = n && n.t === "bin" && ["+", "-"].includes(n.op!); + const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!); + return low || (strong && mid) ? `(${text})` : text; +} + +function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string { + const raw = ARGS.get(look) ?? []; + const args: FindArg[] = conds.map((cd) => { + const old = raw.find((a) => a.key === cd.col); + const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from; + return { + key: cd.col, + rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic), + }; + }); + return writeFind(table, args, look.col); } /** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */ export function setCond( - _lb: LbLogic, - _lookupId: string, - _col: string, - _from: LbRef | LbExpr | null, + lb: LbLogic, + lookupId: string, + col: string, + from: LbRef | LbExpr | null, ): LbLogic { - return notReady(); -} - -/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬 */ -export function setCalc(_lb: LbLogic, _calc: LbCalc, _patch: Partial): LbLogic { - return notReady(); + const look = lb.lookups.find((x) => x.id === lookupId); + if (!look) return lb; + const has = look.conds.some((x) => x.col === col); + const conds = + from === null + ? look.conds.filter((x) => x.col !== col) + : has + ? look.conds.map((x) => (x.col === col ? { col, from } : x)) + : [...look.conds, { col, from }]; + const text = findText(look, conds, look.table); + return splice( + lb, + look.at.map((a) => ({ ...a, text })), + ); } /** 표 조회가 보는 표 바꾸기(현장 사본 등) */ -export function setLookupTable(_lb: LbLogic, _lookupId: string, _table: string): LbLogic { - return notReady(); +export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic { + const look = lb.lookups.find((x) => x.id === lookupId); + if (!look) return lb; + const text = findText(look, look.conds, table); + return splice( + lb, + look.at.map((a) => ({ ...a, text })), + ); +} + +/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */ +export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial): LbLogic { + const next: LbCalc = { ...calc, ...patch }; + const old = textAt(writeLogic(lb), calc.at.part, calc.at.index); + const text = patch.text !== undefined ? patch.text : buildText(next, lb, old); + return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]); +} + +function buildText(c: LbCalc, lb: LbLogic, old: string): string { + const parts = c.parts.map((p) => refText(p, lb)); + switch (c.kind) { + case "값": + return parts[0] ?? old; + case "할증": + return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`; + case "곱나눔": + case "더하기": { + const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+")); + const strong = c.kind === "곱나눔"; + return parts.reduce( + (acc, p, i) => + i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false), + "", + ); + } + default: + return c.cond ? writeRead(c.cond) : old; + } } diff --git a/resources/tester/test_m01_lb_blocks.py b/resources/tester/test_m01_lb_blocks.py new file mode 100644 index 000000000..f9e9f4188 --- /dev/null +++ b/resources/tester/test_m01_lb_blocks.py @@ -0,0 +1,309 @@ +"""M01 로직 만들기 판 Blocks 시험(35-2a) — Node 로 `M01_LB_Blocks.ts` 를 바로 돌림 · 화면 조작은 ORCA. + +· 견본 로직 줄 여섯(정함 10)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 +· 왕복(readLogic → writeLogic 이 원래 줄과 같음) · 블록 내용 · 조건 바꾸기 · 표 바꾸기 · 계산 바꾸기 · 빈 로직 · 못 읽는 꼴 +· EasyCalc Model · Table · Cond 를 그대로 부름 — 화면 모듈(@ui · 서버 길)은 시험 고리가 빈 것으로 갈음 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BLOCKS = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Blocks.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ──────────── +HOOKS = r""" +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks)(\.ts)?$/; +export async function resolve(spec, ctx, next) { + if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { + const u = new URL(spec + ".ts", ctx.parentURL); + if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true }; + } + return next(spec, ctx); +} +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\s+"[^"]+\.css";/g, ""); + src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => { + if (from.startsWith(".") && ALLOW.test(from)) return m; + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" +STUB = ( + "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : " + "k === 'API_BASE_URL' ? '/api' : () => undefined });\n" +) + + +def _run(body: str, *args: object) -> object: + """`B` = Blocks 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답""" + script = ( + "import { register } from 'node:module';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + + STUB + + f"const B = await import({json.dumps(BLOCKS.as_uri())});\n" + "const A = JSON.parse(process.argv[1]);\n" + "const out = (x) => console.log(JSON.stringify(x));\n" + "const META = { key: 'GX900001', version: 'v1', isBase: false };\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script, json.dumps(list(args))], + capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +# ── 견본 줄 여섯(정함 10) ────────────────────────────────────────────────── +H = {"원문번호": "", "출처": "자체", "끝수": None} +GF000208 = { + **H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [], + "호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"}, + "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}], + "덧줄": [], +} # fmt: skip +GF000219 = { + **H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡", + "입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35]}, {"이름": "돌", "고르기": ["깬잡석", "깬돌"]}, + {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "단위": "m"}, + {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}], + "중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}], + "호표": [ + {"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", + "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비"}, + {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", + "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비"}, + ], + "덧줄": [], +} # fmt: skip +GF000025 = { + **H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡", + "입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}], + "중간": [], + "호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", + "수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)", + "비목": "노무비"}], + "덧줄": [], +} # fmt: skip +GF000249 = { + **H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥", + "입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}], + "중간": [{"이름": "작업량", "식": "로직(GF000114)"}], + "호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton", + "단위": "시간", "수량": "1 / 작업량"}], + "덧줄": [], +} # fmt: skip +GF000133 = { + **H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간", + "입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f", "단위": ""}, {"이름": "E", "단위": ""}, {"이름": "Cm", "단위": "분"}], + "중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)", "출처": "건설품셈 공통 8-1-4 주①"}, + "끝수": "소수 3자리 사사오입(건설품셈 공통 8-1-4 주①)", +} # fmt: skip +GC000051 = { + **H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [], + "중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}], + "호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}], + "덧줄": [{"이름": "공구손료 및 경장비", + "식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100", + "비목": "경비", "출처": "건설품셈 공통 2-9-3"}], +} # fmt: skip +SIX = [GF000208, GF000219, GF000025, GF000249, GF000133, GC000051] + + +def _lb(row: dict) -> dict: + return _run("const lb = B.readLogic(A[0], META); out({ ...lb, doc: undefined });", row) + + +# ── 왕복 ─────────────────────────────────────────────────────────────────── +@pytest.mark.parametrize("row", SIX, ids=[r["키"] for r in SIX]) +def test_왕복_같음_다_읽힘(row: dict) -> None: + got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row) + assert got == [row, []] + + +# ── 블록 내용 ─────────────────────────────────────────────────────────────── +def test_메쌓기_표조회_조건목록_할증() -> None: + lb = _lb(GF000219) + assert [(x["id"], x["table"], x["col"]) for x in lb["lookups"]] == [ + ("L1", "QF000422", "증가율"), + ("L2", "QF000421", "석공"), + ("L3", "QF000421", "보통인부"), + ] + assert lb["lookups"][1]["conds"] == [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "input", "name": "돌"}}, + {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, + ] + assert lb["lookups"][1]["at"] == [{"part": "수량", "index": 0, "start": 0, "end": 36}] + mid, stone, worker = lb["calcs"] + assert (mid["name"], mid["kind"], mid["at"]) == ( + "증가율", + "조건목록", + {"part": "중간", "index": 0}, + ) + assert mid["cond"]["blocks"][0]["shape"]["type"] == "if" + assert (stone["kind"], stone["parts"]) == ( + "할증", + [{"k": "lookup", "id": "L2"}, {"k": "calc", "name": "증가율"}], + ) + assert worker["parts"][0] == {"k": "lookup", "id": "L3"} + assert lb["result"] == {"kind": "금액", "unit": "원/㎡"} + assert [x["이름"] for x in lb["inputs"]] == ["뒷길이", "돌", "쌓기", "높이", "초과증가율"] + + +def test_떼붙임_다짐_덤프_안전통로() -> None: + turf = _lb(GF000025) + assert turf["lookups"][0]["conds"] == [ + {"col": "구분", "from": {"k": "fixed", "value": "비탈구배 해당 시 품 할증"}} + ] + c = turf["calcs"][0] + assert c["kind"] == "할증" and c["parts"][0] == {"k": "input", "name": "품"} + assert c["parts"][1]["k"] == "expr" and c["cond"]["blocks"][0]["shape"]["type"] == "if" + + tamp = _lb(GF000249) + work, qty = tamp["calcs"] + assert work["kind"] == "값" and work["parts"] == [ + {"k": "logic", "key": "GF000114", "field": None} + ] + assert (qty["kind"], qty["ops"]) == ("곱나눔", ["/"]) + assert qty["parts"] == [{"k": "fixed", "value": 1}, {"k": "calc", "name": "작업량"}] + + dump = _lb(GF000133) + assert dump["result"]["kind"] == "값" and dump["result"]["unit"] == "㎥/시간" + assert dump["result"]["calc"]["kind"] == "올림" and dump["calcs"] == [dump["result"]["calc"]] + + walk = _lb(GC000051) + rate = walk["calcs"][-1] + assert rate["at"] == {"part": "비율", "index": 0} and rate["ops"] == ["*", "/"] + assert rate["parts"] == [ + {"k": "cost", "name": "사람줄합"}, + {"k": "lookup", "id": "L2"}, + {"k": "fixed", "value": 100}, + ] + + fill = _lb(GF000208) + assert fill["lookups"] == [] and fill["calcs"][0]["parts"] == [{"k": "fixed", "value": 1}] + + +# ── 고치기 ───────────────────────────────────────────────────────────────── +def _ho(row: dict) -> list[str]: + return [h["수량"] for h in row["호표"]] + + +def test_조건_바꾸기_빼기_표_바꾸기() -> None: + got = _run( + "let lb = B.readLogic(A[0], META);\n" + "const a = B.writeLogic(B.setCond(lb, 'L2', '돌', { k: 'fixed', value: '깬돌' }));\n" + "const b = B.writeLogic(B.setCond(lb, 'L2', '쌓기', null));\n" + "const c = B.writeLogic(B.setCond(lb, 'L1', '높이', { k: 'fixed', value: 5 }));\n" + "const d = B.writeLogic(B.setLookupTable(lb, 'L3', 'QX000001'));\n" + "out([a, b, c, d]);", + GF000219, + ) + a, b, c, d = got + assert _ho(a) == [ + "찾기(QF000421, 뒷길이=뒷길이, 돌='깬돌', 쌓기=쌓기).석공 * (1 + 증가율 / 100)", + GF000219["호표"][1]["수량"], + ] + assert _ho(b)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)" + assert ( + c["중간"][0]["식"] + == "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=5).증가율, 초과증가율))" + ) + assert ( + _ho(d)[1] == "찾기(QX000001, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)" + ) + assert _ho(d)[0] == GF000219["호표"][0]["수량"] + + +def test_계산_바꾸기() -> None: + got = _run( + "const lb = B.readLogic(A[0], META);\n" + "const [mid, stone] = lb.calcs;\n" + "const r1 = B.writeLogic(B.setCalc(lb, stone, { parts: [stone.parts[0], { k: 'fixed', value: 10 }] }));\n" + "const cond = structuredClone(mid.cond); cond.blocks[0].shape.otherwise = { kind: 'num', value: 90 };\n" + "const r2 = B.writeLogic(B.setCalc(lb, mid, { cond }));\n" + "const r3 = B.writeLogic(B.setCalc(lb, stone, { kind: '값', parts: [{ k: 'fixed', value: 0.3 }] }));\n" + "const r4 = B.writeLogic(B.setCalc(lb, stone, { text: '0.25 * 2' }));\n" + "const after = B.readLogic(r1, META);\n" + "out([r1, r2, r3, r4, after.calcs[1].parts, after.lookups.map((x) => x.id)]);", + GF000219, + ) + r1, r2, r3, r4, parts, ids = got + assert _ho(r1)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 10 / 100)" + assert _ho(r1)[1] == GF000219["호표"][1]["수량"] + assert ( + r2["중간"][0]["식"] + == "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 90))" + ) + assert _ho(r3)[0] == "0.3" and _ho(r4)[0] == "0.25 * 2" + assert parts == [{"k": "lookup", "id": "L2"}, {"k": "fixed", "value": 10}] + assert ids == ["L1", "L2", "L3"] + + +def test_곱나눔_더하기_괄호() -> None: + got = _run( + "const lb = B.readLogic(A[0], META); const q = lb.calcs[1];\n" + "const r1 = B.writeLogic(B.setCalc(lb, q, { parts: [{ k: 'fixed', value: 2 }, q.parts[1]] }));\n" + "const r2 = B.writeLogic(B.setCalc(lb, q, { parts: [q.parts[0], { k: 'expr', text: '작업량 + 1' }] }));\n" + "const r3 = B.writeLogic(B.setCalc(lb, q, { kind: '더하기', ops: ['-'], parts: [{ k: 'fixed', value: 3 }," + " { k: 'expr', text: '작업량 - 1' }] }));\n" + "out([r1.호표[0].수량, r2.호표[0].수량, r3.호표[0].수량, r1.중간]);", + GF000249, + ) + assert got == ["2 / 작업량", "1 / (작업량 + 1)", "3 - (작업량 - 1)", GF000249["중간"]] + + +# ── 빈 로직 · 못 읽는 꼴 ──────────────────────────────────────────────────── +def test_빈_로직() -> None: + got = _run( + "const lb = B.newLogic('새 일', 'm'); out([B.writeLogic(lb), lb.lookups, lb.calcs, lb.result, lb.blocked]);" + ) + row, looks, calcs, result, blocked = got + assert row["이름"] == "새 일" and row["결과단위"] == "원/m" and row["출처"] == "자체" + assert (looks, calcs, result, blocked) == ([], [], {"kind": "금액", "unit": "원/m"}, []) + + +def test_못_읽는_꼴은_쉬운말_왕복은_같음() -> None: + row = { + **GF000219, + "중간": [ + { + "이름": "증가율", + "식": "만약(높이 > 3 그리고 높이 <= 7.5, 찾기(QF000422, 높이=최소(높이, 7.5)).증가율, 0)", + } + ], + } + got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row) + assert got[0] == row + assert got[1] == [ + "증가율 — 조건에 「그리고 · 또는」이 섞임", + "증가율 — 표 조회 조건에 계산이 들어 있음", + ] + + +def test_화면_글에_금지_낱말_없음() -> None: + src = BLOCKS.read_text(encoding="utf-8") + block = src[src.index("const TEXT = {") : src.index("} as const;")] + korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] + assert len(korean) >= 7 + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체", "잠금"): + assert bad not in text, (bad, text)