From 7ad2f2c133669aa6f1fedccfaa90d558d21495a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:24:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=A0=95=ED=95=A8=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=A8=BC=EC=A0=80?= =?UTF-8?q?=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98=EA=B8=B0=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/흐름도_적용_정함.md | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_정함.md b/docs/raw/M01_쉬운단가/흐름도_적용_정함.md new file mode 100644 index 000000000..2536a147b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_정함.md @@ -0,0 +1,37 @@ +# 흐름도 본 프로그램 적용 — 정함 (PLAN 36 · 브레인 · 10-01 23시) + +사용자 지시 · 결정은 PLAN.md 36장 머리 줄(그대로 인용)이 기준. 아래는 창끼리 맞출 약속. + +## 목적 + +「단가산출 로직」 화면에서 로직을 열면 왼쪽 = 지금 내용(보기) · 오른쪽 = 같은 로직의 흐름도(고치는 곳). 한 로직 · 두 보기 · 고칠 곳은 흐름도 한 곳. 옛 로직은 열 때 흐름도로 자동 변환(파일은 안 바뀜) · 흐름도로 [저장]하면 그 로직은 흐름도가 정본. + +## 약속 + +1. **로직 줄 「흐름」 칸** — 마스터 로직 줄(`resources/master_data/로직_*.json` 의 `줄[]`)에 `흐름`(CfLogic JSON · DataFlow Model 꼴 그대로). 있으면 흐름도가 정본. 쓰는 때 = 화면 [저장](기존 LogicLab `POST /save` 의 줄에 실어 보냄)뿐 · 자동 변환 결과를 파일에 쓰지 않음. 서버는 저장 때 흐름을 검사(Model 검사 꼴)하고 서버 셈으로 다시 셈. +2. **흐름 받기 길** — `GET /api/m01/logic/flow?file=&key=` → `{flow: CfLogic, converted: bool, notes: string[]}` · 줄에 `흐름` 이 있으면 그대로(converted false) · 없으면 변환(converted true · notes = 못 옮긴 곳 쉬운 말). 변환 모듈 = `M02_MasterTemplete/DataFlow/M02_DF_Convert.py`(sub7 · 서버 단독 · 옛 식 파서는 지금 서버가 쓰는 것을 그대로 씀). +3. **변환 규칙** — 입력 → 입력 상자(변수 · 고르기 = choice · 범위) · 중간 식 → 함수 상자(식 하나 = 결과 하나) · 호표 줄 → 요소 알약(단가) + 수량 함수 + 출력 상자(비목별: 노무비 · 재료비 · 경비) · 결과 식 · 덧줄 · 끝수 → 함수 · 끝수 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(M02 방식) · 만약 → IF · 또는 → OR · 최대/최소 → MAX/MIN · 올림/반올림/버림 → ROUNDUP/ROUND/ROUNDDOWN · 찾기(표, 조건=값).칸 → 표 알약 + 선 규칙 「표에서 찾기」 · 로직(…) → 다른 로직 입력 상자 · 자동 배치(입력 왼쪽 → 함수 가운데 → 출력 오른쪽 · 겹침 없음). 전수 대조 = 1,472 로직마다 옛 셈 값(기본 입력 · 시험입력) = 변환 흐름도 셈 값 · 맞는 비율 · 안 맞는 까닭 목록(읽기만 · 파일 안 씀). +4. **계산 길** — 기존 M01 계산(`/calc` 꼴 · 시험 계산 · 다른 화면이 부르는 셈)이 줄에 `흐름` 이 있으면 DataFlow 서버 셈(`M02_DF_Compute.py`)으로 · 없으면 지금 셈 그대로. 입력 이름 = 흐름의 「설계 때 정할 것」(변수 이름) 그대로. +5. **오른쪽 패널** — `M02_MasterTemplete/DataFlow/M02_DF_Panel.ts`(sub8) `mountFlowPanel(host, opts) → handle` · opts = { logic: CfLogic; readOnly?: boolean; title: string; onChange(lg: CfLogic): void; logics?(key) } · handle = { set(lg), destroy(), expand() } · 다이어그램 · 상세 내용 · 원가 · 검증 칸을 패널 안에(세로로) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면 · 닫으면 패널로) · 저장 단추는 패널에 없음(페이지 [저장] 하나) · 실행 취소는 패널 안. +6. **페이지 저장 하나** — 패널 조작 = 페이지 캐시(drafts · sessionStorage)에 쌓임 · [저장] 한 번에 줄(흐름 포함) 보냄 · [고친 것 버리기] = 흐름도 고친 것도 버림 · 자동저장 없음. +7. **왼쪽 보기(흐름 로직)** — 기본정보(지금대로) · 단가산출 상세 = 흐름도 출력 상자를 비용 종류별 줄(이름 · 단위 · 값) · 소계 · 계 · 텍스트 수식 = 함수 상자마다 「식 보기」 두 줄(이름 식 · 값 식) · 시험 계산 = 계산 길(4) 그대로. 흐름 없는 옛 로직의 왼쪽은 지금 그대로. +8. **식 보기 · 조건 만들기** — `M02_DF_FormulaView.ts`(sub2) `formulaLines(box: CfBox, values) → { name: string; named: string; valued: string; branches?: {when: string; then: string; now: boolean}[] }[]` (칸 주소 → 연결 알약 이름 · ×, ÷ · IF 는 만약 / 아니면 줄) — 상세 내용 함수 판과 왼쪽 텍스트 수식(sub5)이 함께 씀. 「조건 만들기」 = 엑셀 시트 안 기능(`A00_Common/spreadsheet/spreadsheet_condition.ts` 새 파일 · 편집기 우클릭 「조건 만들기」 · 부르는 쪽이 `conditions` 선택을 줄 때만 보임 → M02 화면은 그대로) · 만약 [칸 · 알약] [비교] [값] → [칸 · 값] 줄 · 아니면 만약 · 아니면 → 고른 칸에 IF 식 · 값 목록은 부르는 쪽 `valuesFor(a1)` 이 줌. +9. **선 조건(조건 통과)** — 웹앱 모양 그대로 · CfRule gate = `{ kind: "gate"; conds: { from: CfPort; op; value }[]; join: "그리고" | "또는" }`(옛 한 줄 꼴도 읽음) · Model `valueChoices(lg, port) → (string | number)[] | null`(변수 고르기 · 표 칸 값 · 재료 규격 · 없으면 null = 숫자 칸 + 범위 검사) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 이름표 「지반 = 토사 그리고 높이 ≥ 3 일 때만」. +10. **단추** — 「식으로 새로 만들기」 지움 · 「신규 로직」(빈 흐름도 + 기본정보 칸 이름 · 원문번호 · 결과 단위 · 출처 · 구분 자체 → drafts 새 줄 → [저장] 때 서버가 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽 · 흐름도 있으면 함께 복사) · 「시험판 ▾」 과 시험판 단추 둘 지움. +11. **다른 로직 입력** — 고를 대상 = 마스터 로직 전체(기존 로직 목록 찾기) · 내보내는 알약 = 그 로직 출력(흐름이면 출력 상자 · 옛 로직이면 노무비 · 재료비 · 경비 · 계) · 받는 알약 = 그 로직 입력 · 값 = 서버 계산 길(4). +12. **권한** — 흐름도 고치기 · 저장 = 시스템 관리자(공용 로직 포함) · 그 밖은 보기만. +13. **안전** — 서버는 환경 변수 `AISLO_MASTER_DIR` 가 있으면 그 폴더를 마스터 폴더로(없으면 지금대로 · sub4). 화면 저장 시험은 자기 폴더 `tmp/master_copy_36/`(resources/master_data 사본) + 그 변수로 띄운 자기 서버에서만 · 끝나면 git status 로 resources/master_data 안 바뀜 확인. 시험(pytest)은 사본 폴더 · 마스터 내용에 기대지 않음. +14. **정리** — DataFlow · CostFlow 폴더 그대로 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 `resources/tester/fixtures_df/` 로 옮기고 그 밖 견본 지움 · 시험판 입구 지움. + +## 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 36-0b 선 조건 틀(gate conds · join · valueChoices · Rule 식 · 시험) 먼저 → 36-2 변환(M02_DF_Convert.py · 자동 배치 · 전수 대조) | +| sub2 | 36-0 상세 내용 함수 판 「식 보기」 · 칸 연결 표 · 칸 색 · M02_DF_FormulaView.ts · 엑셀 「조건 만들기」(spreadsheet_condition.ts) · LinkPanel 조건 여러 줄 · 값 목록 고르기 | +| sub6 | 36-1 조사(옛 식 문법 전수 · 파서 위치 · 못 옮기는 꼴 · 계산 길을 부르는 곳) → sub7 에 넘김 → 36-7 정리(견본 · 시험 옮김) | +| sub8 | 36-3 M02_DF_Panel.ts(패널 꼴 · 너비 · 접기 · 크게 보기) · 선 이름표 여러 조건 | +| sub4 | 36-4 서버 — 「흐름」 칸 저장 · 검사 · /api/m01/logic/flow · 계산 길 갈래 · 신규 GX · 복사에 흐름 · 권한 · AISLO_MASTER_DIR · Compute 여러 조건 | +| sub5 | 36-4 왼쪽 보기(흐름 로직의 단가산출 상세 · 텍스트 수식 · 시험 계산) | +| sub3 | 36-6 다른 로직 입력 = 마스터 로직 고르기 · 값 | +| sub1 | 36-5 단추 · 시험판 지움 · LogicLab 두 칸 배치 · 패널 잇기 · drafts 저장 하나 → 36-8 끝 검증 | From e799fbf345570d3fca340c91a2da245110d4b76a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:30:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=B4=EA=B8=B0=20FormulaView=20=C2=B7=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=A1=B0=EA=B1=B4=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20spreadsheet=5Fcondition(=EC=9A=B0=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=20=C2=B7=20conditions=20=EC=A4=84=20=EB=95=8C=EB=A7=8C)=20+=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- A00_Common/spreadsheet/spreadsheet.ts | 7 + .../spreadsheet/spreadsheet_condition.css | 61 +++ .../spreadsheet/spreadsheet_condition.ts | 367 ++++++++++++++++++ .../spreadsheet/spreadsheet_condition_text.ts | 121 ++++++ .../DataFlow/M02_DF_FormulaView.ts | 284 ++++++++++++++ resources/tester/test_m02_df_formula_view.py | 180 +++++++++ 6 files changed, 1020 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_condition.css create mode 100644 A00_Common/spreadsheet/spreadsheet_condition.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_condition_text.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts create mode 100644 resources/tester/test_m02_df_formula_view.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 583cc173d..87ec2cc7b 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -22,6 +22,7 @@ import { createCalcEngine } from "./spreadsheet_graph"; import { createGrid } from "./spreadsheet_grid"; import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; +import type { ConditionOptions } from "./spreadsheet_condition"; import type { LinkOptions, Links } from "./spreadsheet_links"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; @@ -62,6 +63,8 @@ export interface SpreadsheetOptions { /** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 · * 알약 막대만. 다른 화면 모양은 그대로 */ preview?: boolean; + /** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */ + conditions?: ConditionOptions; } export interface SpreadsheetHandle { @@ -165,6 +168,10 @@ export function createSpreadsheet( grid.render(); select(ctx.selection); loadLinks(); + if (opts.conditions && !readOnly) + void import("./spreadsheet_condition").then((m) => { + if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!)); + }); function notify(message: string): void { toast.textContent = message; diff --git a/A00_Common/spreadsheet/spreadsheet_condition.css b/A00_Common/spreadsheet/spreadsheet_condition.css new file mode 100644 index 000000000..d98c625e3 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition.css @@ -0,0 +1,61 @@ +.ss-cond-menu { + position: fixed; + z-index: 10000; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.ss-cond { + min-width: min(46rem, 96vw); +} + +.ss-cond-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; +} + +.ss-cond-label { + min-width: 5em; + font-weight: var(--font-weight-bold); +} + +.ss-cond-pick { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.ss-cond-cell, +.ss-cond-value { + width: 7em; +} + +.ss-cond .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.ss-cond-note { + margin: 0 0 var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); +} + +.ss-cond-formula { + flex: 1 1 20em; + overflow-wrap: anywhere; + color: var(--color-text-secondary); +} + +.ss-cond-foot { + margin-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} diff --git a/A00_Common/spreadsheet/spreadsheet_condition.ts b/A00_Common/spreadsheet/spreadsheet_condition.ts new file mode 100644 index 000000000..628fd3d4f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition.ts @@ -0,0 +1,367 @@ +/* ============================================================================= + * spreadsheet_condition.ts + * 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF) + * 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내 + * 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로 + * 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import { toA1 } from "./spreadsheet_address"; +import { + COND_OPS, + CELL, + draftToFormula, + formulaToDraft, + type CondBranch, + type CondDraft, + type CondOp, + type CondOperand, +} from "./spreadsheet_condition_text"; +import "./spreadsheet_condition.css"; + +/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */ +export interface ConditionOptions { + valuesFor(a1: string): (string | number)[] | null; + pills?(): { name: string; cell: string }[]; +} + +export { COND_OPS, draftToFormula, formulaToDraft }; +export type { CondBranch, CondDraft, CondOp, CondOperand }; + +/* ── 글 ─────────────────────────────────────────────────────────────────── */ + +const TEXT = { + Menu: ["조건 만들기", "Make a condition"], + Title: ["조건 만들기", "Make a condition"], + If: ["만약", "If"], + ElseIf: ["아니면 만약", "Else if"], + Then: ["→", "→"], + Otherwise: ["아니면 →", "Otherwise →"], + AddElseIf: ["+ 아니면 만약", "+ Else if"], + Remove: ["지움", "Remove"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Close: ["닫기", "Close"], + CellPick: ["칸 직접", "A cell"], + ValuePick: ["값", "A value"], + Cell: ["칸 주소", "Cell address"], + Value: ["값", "Value"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], + Target: ["식이 들어갈 칸", "Formula goes into"], + AsIs: [ + "이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침", + "This formula can't be shown as lines — edit the formula as it is", + ], +} as const; + +const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 줄 판 ───────────────────────────────────────────────────────────────── */ + +const OWN = "\u0001cell"; +const OWN_VALUE = "\u0001value"; + +function select( + options: { value: string; text: string }[], + value: string, + onPick: (v: string) => void, + aria: string, +): HTMLSelectElement { + const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); + for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); + s.value = value; + s.addEventListener("change", () => onPick(s.value)); + return s; +} + +/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */ +function cellPick( + opts: ConditionOptions, + value: string, + onChange: (cell: string) => void, + aria: string, +): HTMLElement { + const pills = opts.pills?.() ?? []; + const hit = pills.find((p) => p.cell === value); + const options = [ + ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), + { value: OWN, text: tc("CellPick") }, + ]; + const box = el("span", { className: "ss-cond-pick" }); + const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria); + box.append(sel); + if (!hit) { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-cell", + attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" }, + }); + input.value = value; + input.addEventListener("change", () => { + const m = CELL.exec(input.value.trim().toUpperCase()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tc("BadCell"); + if (m) onChange(`${m[1]}${m[2]}`); + }); + box.append(input); + } + return box; +} + +/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */ +function operandPick( + opts: ConditionOptions, + o: CondOperand, + hint: string | null, + onChange: (o: CondOperand) => void, + aria: string, +): HTMLElement { + const box = el("span", { className: "ss-cond-pick" }); + const pills = opts.pills?.() ?? []; + const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell); + const options = [ + ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), + { value: OWN, text: tc("CellPick") }, + { value: OWN_VALUE, text: tc("ValuePick") }, + ]; + const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN; + box.append( + select( + options, + mode, + (v) => { + if (v === OWN_VALUE) onChange({ kind: "value", value: 0 }); + else if (v === OWN) onChange({ kind: "cell", cell: "B2" }); + else onChange({ kind: "cell", cell: v }); + }, + aria, + ), + ); + if (o.kind === "cell" && !isPill) { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-cell", + attrs: { type: "text", "aria-label": tc("Cell") }, + }); + input.value = o.cell; + input.addEventListener("change", () => { + const m = CELL.exec(input.value.trim().toUpperCase()); + input.classList.toggle("is-bad", !m); + if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` }); + }); + box.append(input); + } + if (o.kind === "value") { + const list = hint ? opts.valuesFor(hint) : null; + if (list && list.length) { + const key = (v: string | number): string => `${typeof v}:${v}`; + box.append( + select( + list.map((v) => ({ value: key(v), text: String(v) })), + key(o.value), + (k) => { + const hitv = list.find((v) => key(v) === k); + if (hitv !== undefined) onChange({ kind: "value", value: hitv }); + }, + tc("Value"), + ), + ); + } else { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-value", + attrs: { type: "text", "aria-label": tc("Value") }, + }); + input.value = String(o.value); + input.addEventListener("change", () => { + const t = input.value.trim(); + onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t }); + }); + box.append(input); + } + } + return box; +} + +function openJob( + ctx: SpreadsheetContext, + opts: ConditionOptions, + a1: string, + draft: CondDraft | null, + existing: string | null, +): void { + openModal({ + title: `${tc("Title")} — ${a1}`, + closeLabel: tc("Close"), + dialogClass: "ss-cond", + mount: (body, close) => { + let d: CondDraft = draft ?? { + branches: [ + { + left: "B1", + op: "=", + right: { kind: "value", value: "" }, + then: { kind: "value", value: 0 }, + }, + ], + otherwise: { kind: "value", value: 0 }, + }; + if (existing !== null && !draft) { + body.append( + el("p", { className: "ss-cond-note", text: tc("AsIs") }), + el("code", { className: "ss-cond-formula", text: `=${existing}` }), + ); + return; + } + const draw = (): void => { + const set = (next: CondDraft): void => ((d = next), draw()); + const rows = d.branches.map((b, i) => { + const edit = (patch: Partial): void => + set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); + return el("div", { + className: "ss-cond-row", + children: [ + el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }), + cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")), + select( + COND_OPS.map((o) => ({ value: o, text: o })), + b.op, + (v) => edit({ op: v as CondOp }), + "op", + ), + operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")), + tc("Then"), + operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")), + d.branches.length > 1 + ? createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }), + }) + : "", + ], + }); + }); + const last = d.branches[d.branches.length - 1]; + body.replaceChildren( + ...rows, + el("div", { + className: "ss-cond-row", + children: [ + createButton({ + label: tc("AddElseIf"), + variant: "ghost", + size: "sm", + onClick: () => + set({ + ...d, + branches: [ + ...d.branches, + { + left: last.left, + op: "=", + right: { kind: "value", value: "" }, + then: { kind: "value", value: 0 }, + }, + ], + }), + }), + ], + }), + el("div", { + className: "ss-cond-row", + children: [ + el("span", { className: "ss-cond-label", text: tc("Otherwise") }), + operandPick( + opts, + d.otherwise, + null, + (otherwise) => set({ ...d, otherwise }), + tc("Otherwise"), + ), + ], + }), + el("div", { + className: "ss-cond-row ss-cond-foot", + children: [ + el("code", { + className: "ss-cond-formula", + text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`, + }), + createButton({ + label: tc("Make"), + variant: "filled", + size: "sm", + onClick: () => { + const sheet = ctx.sheet(); + const { 값: _v, ...keep } = sheet.칸[a1] ?? {}; + ctx.dispatch({ + 종류: "칸", + 시트: sheet.id, + 칸: { [a1]: { ...keep, 식: draftToFormula(d) } }, + }); + close(); + }, + }), + createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }), + ], + }), + ); + }; + draw(); + }, + }); +} + +/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */ +export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle { + let menu: HTMLElement | null = null; + const close = (): void => { + menu?.remove(); + menu = null; + document.removeEventListener("pointerdown", away, true); + }; + const away = (e: Event): void => { + if (menu && !menu.contains(e.target as Node)) close(); + }; + const onContext = (e: MouseEvent): void => { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + if (!hit || hit.kind !== "cell" || ctx.readOnly) return; + e.preventDefault(); + e.stopPropagation(); + close(); + const a1 = toA1(hit.r, hit.c); + const sheet = ctx.sheet(); + const cell = sheet.칸[a1]; + menu = el("div", { className: "ss-cond-menu" }); + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + menu.append( + createButton({ + label: tc("Menu"), + variant: "ghost", + size: "sm", + onClick: () => { + close(); + const existing = cell?.식 ?? null; + const draft = existing !== null ? formulaToDraft(existing) : null; + openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null); + }, + }), + ); + document.body.append(menu); + document.addEventListener("pointerdown", away, true); + }; + ctx.root.addEventListener("contextmenu", onContext, true); + return { + root: null, + refresh() {}, + destroy() { + ctx.root.removeEventListener("contextmenu", onContext, true); + close(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_condition_text.ts b/A00_Common/spreadsheet/spreadsheet_condition_text.ts new file mode 100644 index 000000000..24e26fdbb --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition_text.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * spreadsheet_condition_text.ts + * 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림 + * ========================================================================== */ + +export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<"; +export type CondOperand = + { kind: "cell"; cell: string } | { kind: "value"; value: number | string }; +export interface CondBranch { + /** 비교하는 칸 */ + left: string; + op: CondOp; + right: CondOperand; + then: CondOperand; +} +export interface CondDraft { + branches: CondBranch[]; + otherwise: CondOperand; +} + +export const XL_OP: Record = { + "=": "=", + "≠": "<>", + "≥": ">=", + "≤": "<=", + ">": ">", + "<": "<", +}; +export const FROM_XL: Record = { + "=": "=", + "<>": "≠", + ">=": "≥", + "<=": "≤", + ">": ">", + "<": "<", +}; +export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"]; + +export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/; + +/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */ + +export const operandText = (o: CondOperand): string => + o.kind === "cell" + ? o.cell + : typeof o.value === "number" + ? String(o.value) + : `"${o.value.replace(/"/g, '""')}"`; + +/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */ +export function draftToFormula(d: CondDraft): string { + return d.branches.reduceRight( + (rest, b) => + `IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`, + operandText(d.otherwise), + ); +} + +/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */ +export function splitArgs(s: string): string[] { + const out: string[] = []; + let depth = 0; + let quote = false; + let from = 0; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') quote = !quote; + else if (quote) continue; + else if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === "," && depth === 0) { + out.push(s.slice(from, i)); + from = i + 1; + } + } + out.push(s.slice(from)); + return out; +} + +export function parseOperand(raw: string): CondOperand | null { + const t = raw.trim(); + const cell = CELL.exec(t); + if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` }; + if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) }; + const q = /^"((?:[^"]|"")*)"$/.exec(t); + return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null; +} + +/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */ +export function ifBody(f: string): string | null { + const t = f.trim(); + if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null; + let depth = 0; + let quote = false; + for (let i = 2; i < t.length; i++) { + if (t[i] === '"') quote = !quote; + else if (quote) continue; + else if (t[i] === "(") depth++; + else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null; + } + return t.slice(3, -1); +} + +/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */ +export function formulaToDraft(formula: string): CondDraft | null { + const body = ifBody(formula); + if (body === null) return null; + const args = splitArgs(body); + if (args.length !== 3) return null; + const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]); + if (!m) return null; + const left = parseOperand(m[1]); + const right = parseOperand(m[3]); + const then = parseOperand(args[1]); + if (!left || left.kind !== "cell" || !right || !then) return null; + const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then }; + const plain = parseOperand(args[2]); + if (plain) return { branches: [branch], otherwise: plain }; + const rest = formulaToDraft(args[2]); + return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts new file mode 100644 index 000000000..8d9581eb4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * M02_DF_FormulaView.ts + * 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 · + * IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text"; +import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CfBox } from "./M02_DF_Model"; + +export type CellScalar = number | string | boolean | null; + +export interface FormulaBranch { + when: string; + then: string; + /** 지금 타는 갈래 */ + now: boolean; +} + +export interface FormulaLine { + /** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */ + name: string; + /** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */ + named: string; + /** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */ + valued: string; + /** IF 면 만약 / 아니면 줄 */ + branches?: FormulaBranch[]; +} + +const TEXT = { + Title: ["식 보기", "Formulas"], + Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"], + Else: ["아니면", "Otherwise"], + If: ["만약", "If"], + Now: ["지금", "now"], +} as const; + +const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */ + +const REF = /(? string): string { + return text + .split(/("(?:[^"]|"")*")/) + .map((p, i) => (i % 2 ? p : fn(p))) + .join(""); +} + +const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, ""); + +/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */ +export function cellNames(box: CfBox): Record { + const f = box.func; + const out: Record = {}; + if (!f) return out; + for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port; + for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name; + if (f.output.cell) out[upper(f.output.cell)] = f.output.name; + return out; +} + +const refsIn = (formula: string): string[] => { + const found: string[] = []; + outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m))); + return found; +}; + +const OPS: [RegExp, string][] = [ + [/\*/g, " × "], + [/\//g, " ÷ "], + [/<>/g, " ≠ "], + [/>=/g, " ≥ "], + [/<=/g, " ≤ "], + [/(?≠≥≤])=/g, " = "], + [/\+/g, " + "], + [/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "], +]; + +/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */ +function pretty(text: string): string { + return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p)) + .replace(/\s{2,}/g, " ") + .trim(); +} + +const fmt = (n: number): string => + Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +function showScalar(v: CellScalar | undefined): string { + if (v === undefined || v === null) return "0"; + if (typeof v === "number") return fmt(v); + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return `"${v}"`; +} + +/** 이름으로 쓴 식 */ +export function namedText(formula: string, names: Record): string { + return pretty( + outside(formula, (p) => + p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`), + ), + ); +} + +/** 값 넣은 식(값이 문자면 따옴표) */ +export function valuedText(formula: string, values: Record): string { + return pretty( + outside(formula, (p) => + p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])), + ), + ); +} + +/* ── 갈래 ───────────────────────────────────────────────────────────────── */ + +const num = (v: CellScalar | undefined): number | null => { + if (typeof v === "number") return v; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); + return null; +}; + +/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */ +function holds(cond: string, values: Record): boolean | null { + const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond); + if (!m) return null; + const read = (t: string): CellScalar | undefined => { + const x = t.trim(); + const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x); + if (cell) return values[`${cell[1]}${cell[2]}`]; + const q = /^"((?:[^"]|"")*)"$/.exec(x); + if (q) return q[1].replace(/""/g, '"'); + return Number.isFinite(Number(x)) ? Number(x) : undefined; + }; + const a = read(m[1]); + const b = read(m[3]); + if (a === undefined || b === undefined) return null; + const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)]; + switch (m[2]) { + case "=": + return l === r; + case "<>": + return l !== r; + case ">=": + return l >= r; + case "<=": + return l <= r; + case ">": + return l > r; + default: + return l < r; + } +} + +/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */ +function branchesOf( + formula: string, + names: Record, + values: Record, +): FormulaBranch[] | undefined { + const out: FormulaBranch[] = []; + let rest = formula; + let taken = false; + for (;;) { + const body = ifBody(rest); + if (body === null) break; + const a = splitArgs(body); + if (a.length !== 3) return undefined; + const ok = holds(a[0], values) === true && !taken; + if (ok) taken = true; + out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok }); + rest = a[2]; + } + if (!out.length) return undefined; + out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken }); + return out; +} + +/* ── 줄 ─────────────────────────────────────────────────────────────────── */ + +/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */ +export function formulaLines(box: CfBox, values: Record): FormulaLine[] { + const f = box.func; + if (!f?.output.cell) return []; + const names = cellNames(box); + const cells = f.sheet.칸; + const order: string[] = []; + const seen = new Set(); + const visit = (a1: string): void => { + const key = upper(a1); + if (seen.has(key)) return; + seen.add(key); + const formula = cells[key]?.식; + if (formula === undefined) return; + for (const r of refsIn(formula)) visit(r); + order.push(key); + }; + visit(f.output.cell); + return order.map((key) => { + const formula = cells[key].식 as string; + const result = showScalar(values[key]); + const branches = branchesOf(formula, names, values); + return { + name: names[key] ?? key, + named: namedText(formula, names), + valued: `${valuedText(formula, values)} = ${result}`, + ...(branches ? { branches } : {}), + }; + }); +} + +/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */ +export function cellValuesOf( + box: CfBox, + arrived: Record | undefined, +): Record { + const f = box.func; + if (!f) return {}; + const sheet = structuredClone(f.sheet); + const scalar = (v: unknown): number | string | boolean | undefined => + typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined; + const put = (cell: string, v: number | string | boolean | undefined): void => { + if (!parseA1(cell) || v === undefined) return; + sheet.칸[upper(cell)] = { 값: v }; + }; + for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port])); + for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example); + const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); + const out: Record = {}; + for (const [key, cell] of Object.entries(sheet.칸)) { + const c: CalcValue | undefined = calc[sheet.id]?.[key]; + out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null; + } + return out; +} + +function fromCalc(v: CalcValue): CellScalar { + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +} + +/* ── 그리기 ─────────────────────────────────────────────────────────────── */ + +/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */ +export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void { + host.classList.add("m02-df-formula"); + host.replaceChildren( + el("strong", { className: "m02-df-formula__title", text: tv("Title") }), + ...(lines.length + ? lines.map((l) => + el("div", { + className: "m02-df-formula__line", + children: [ + el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }), + el("div", { className: "m02-df-formula__valued", text: l.valued }), + ...(l.branches + ? [ + el("ul", { + className: "m02-df-formula__branches", + children: l.branches.map((b, i) => + el("li", { + className: b.now ? "is-now" : "", + text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`, + }), + ), + }), + ] + : []), + ], + }), + ) + : [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]), + ); +} diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py new file mode 100644 index 000000000..d015c2a9e --- /dev/null +++ b/resources/tester/test_m02_df_formula_view.py @@ -0,0 +1,180 @@ +"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로. +견본 함수 상자는 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts" +TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, viewFile, textFile] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); +const cache = {}; +function load(f) { + if (cache[f]) return cache[f].exports; + const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[f] = { exports: {} }); + const req = (id) => { + if (id.startsWith(".") || id.startsWith("@ui/sheet/")) { + const p = id.startsWith("@ui/sheet/") + ? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length)) + : path.resolve(path.dirname(f), id); + return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; + } + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const v = load(viewFile); +const t = load(textFile); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "lines") { + const values = v.cellValuesOf(job.box, job.arrived); + return { values, lines: v.formulaLines(job.box, values) }; + } + if (job.op === "toFormula") return t.draftToFormula(job.draft); + if (job.op === "toDraft") return t.formulaToDraft(job.formula); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(VIEW), str(TEXT)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict: + return { + "id": "f1", + "kind": "function", + "name": "석공 품", + "color": "", + "unit": "인", + "x": 0, + "y": 0, + "func": { + "sheet": {"id": "s1", "이름": "석공 품", "칸": cells}, + "inputs": inputs, + "variables": variables, + "output": {"cell": out_cell, "name": out_name, "unit": "인"}, + }, + } + + +def var(name: str, cell: str, example) -> dict: + return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell} + + +# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100) +PLAIN = func_box( + { + "A1": {"값": "석공량"}, + "B3": {"식": "B1*(1+B2/100)"}, + }, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B3", + "석공 인", +) + + +def test_이름_식과_값_식(): + (got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}]) + assert got["values"]["B3"] == pytest.approx(0.156) + (line,) = got["lines"] + assert line["name"] == "석공 인" + assert line["named"] == "석공량 × (1 + 높이 ÷ 100)" + assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" + assert "branches" not in line + + +def test_중간_칸이_먼저_결과_칸이_마지막(): + box = func_box( + {"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}}, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B4", + "합", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}]) + assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로 + assert got["lines"][1]["named"] == "B3 + 1" + assert got["lines"][1]["valued"] == "8 + 1 = 9" + + +@pytest.mark.parametrize( + ("ground", "now"), + [("토사", [True, False]), ("암", [False, True])], +) +def test_IF_줄과_지금_갈래(ground, now): + box = func_box( + {"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}}, + [{"port": "석공량", "cell": "B1"}], + [{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}], + "B3", + "결과", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}]) + (line,) = got["lines"] + assert [b["now"] for b in line["branches"]] == now + assert line["branches"][0]["when"] == '지반 = "토사"' + assert line["branches"][0]["then"] == "석공량 × 2" + assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]} + assert line["valued"].endswith("= 20" if ground == "토사" else "= 30") + + +def test_조건_만들기_식과_다시_줄(): + draft = { + "branches": [ + { + "left": "B1", + "op": "=", + "right": {"kind": "value", "value": "토사"}, + "then": {"kind": "cell", "cell": "C1"}, + }, + { + "left": "B1", + "op": "≥", + "right": {"kind": "value", "value": 3}, + "then": {"kind": "value", "value": 0.5}, + }, + ], + "otherwise": {"kind": "value", "value": 0}, + } + (formula,) = run([{"op": "toFormula", "draft": draft}]) + assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))' + (back,) = run([{"op": "toDraft", "formula": formula}]) + assert back == draft + + +@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"]) +def test_못_바꾸는_식은_null(formula): + (back,) = run([{"op": "toDraft", "formula": formula}]) + assert back is None From 2911dccc5535d65ee539a8f00c18311a5e27520e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:30:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=A1=B0=EC=82=AC=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=A8=BC=EC=A0=80?= =?UTF-8?q?=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98=EA=B8=B0=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/흐름도_적용_조사.md | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_조사.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_조사.md b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md new file mode 100644 index 000000000..c2672300d --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md @@ -0,0 +1,121 @@ +# 흐름도 본 프로그램 적용 — 36-1 옛 식 조사 (sub6 · 읽기만) + +근거 = 마스터 로직 파일 57 개 · 로직 줄 **1,472**(자체 `로직_자체.json` 포함) · 식을 지금 서버 파서(`master_formula.parse`)로 전부 읽음 — **못 읽는 식 0** · 파일 안 씀. +수 칸 = 「나온 수(식 마디 수) / 그 꼴이 든 로직 수」 · 예 = 키 + 식 앞부분. + +## ① 옛 식을 푸는 서버 코드 + +| 무엇 | 자리 | +| --- | --- | +| 식 문법 · 파서(`_Parser` — 또는 → 그리고 → 비교 → + - → * / → 부호 · `.칸` → 기본) | `resources/master_data/scripts/master_formula.py:56`(`parse` :225 · 함수 목록 `FUNCS` :23 = 만약 · 올림 · 버림 · 반올림 · 최소 · 최대 · 비교 `_OPS` :27) | +| 기본 마디 — `( )` · `'글'` · 수 · `찾기(표, 조건=값…)` · `로직(키, 넘길=값…)` · 함수 · 요소 키(`LB000002`) · `ID:원문번호{입력}`(`EQ:{기계}`) · 이름 | 같은 파일 `primary` :185 · 요소 키 판정 `mk.KEY`(`master_keys.py`) | +| 셈(나무 풀기) — 이름 · 요소 값 · 찾기 · 로직 부름 · 함수 · 사칙 · 비교 · 그리고 / 또는(지름길) | `master_formula.py:385` `evaluate` | +| 표 찾기 — 빈 조건 칸 = 무엇이든 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하) · 여러 줄이면 위 끝 최소 · 찾는 칸 있는 줄 | `master_formula.py:331` `_match` · `:362` `find` | +| 로직 한 줄 셈 — 입력 검사 → 중간 차례 → 결과 식 또는 호표 줄(수량 × 단가 · 로직 줄은 비목 나눔) → 덧줄(앞 줄까지 비목 합) → 끝수 | `master_formula.py:538` `run` · 입력 `_inputs` :489 · 빈 가격 입력 `fill_empty` :479 · 끝수 `cut` :468 · `{입력}` 채우기 `fill` :314 | +| 요소 값 · 재료 단가 | `master_material.py:238` `element` · `master_pick.py:130` `priced` · `:123` `pick`(검색어 묶음 → 품목) | +| 정적 검사(저장 때) | `master_formula.py:744` `check_logic` · `M01_MasterData_Store_Cache.py:69` `_check` | +| M01 셈 진입 | `M01_MasterData/M01_MasterData_Store.py:543` `calc`(사본 읽기 → 줄 바꿔 끼움 `_swap` :524 → `cm.calc`) · `:572` `text`(읽는 식 줄 `master_text.py:493 lines`) · `:563` `auto`(견본 입력) | +| 일괄 | `check_master.py:174` `calc` · `:511` `calc_all`(시험입력으로 전 로직) | + +## ② 1,472 로직 식 말 전수 + +| 꼴 | 나온 수 | 로직 수 | 예(3) | +| --- | --- | --- | --- | +| 따옴표 글 | 12,536 | 1,085 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000273, 기계='6516-0060')` | +| 이름 = 입력 | 10,815 | 1,178 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기(…).칸 | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 찾기(표 · 조건) | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 사칙 * | 7,697 | 1,037 | GC000994 `찾기(QC000237).크롤러드릴_공기식 * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000997 `찾기(QC001866).크레인 * 8 / 시공량` | +| 이름 = 중간값 | 7,367 | 1,184 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 사칙 / | 3,824 | 1,019 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 비교 == | 3,736 | 451 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 함수 만약 | 3,541 | 477 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 호표 요소 = 키(인력) | 3,451 | 1,369 | GC000994 `LB000014` · GC000995 `LB000008` · GC000996 `LB000014` | +| 사칙 + | 3,094 | 668 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 로직(다른 로직 부르기) | 1,116 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 로직 넘길 값 있음 | 1,089 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 요소 = 로직 부르기(장비 · 일위대가) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 비목 없음(로직 줄 → 비목 나눔) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기 조건 없음(표 한 줄) | 1,022 | 286 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 요소 키 부르기(인력) | 924 | 393 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 호표 요소 = 재료 고르기 묶음(구분 · 상세구분 · 검색어) | 673 | 231 | GC000319 `{"구분": "도료·방수·접착", "상세구분": "도료", "검색어": "융착식 프라이머"}` · GC000337 `{"구분": "철근·강재", "상세구분": "철망·와이어메쉬", "검색어": "철망 Φ3.2."}` · GC000723 `{"구분": "잡재료", "상세구분": "용접·가스", "검색어": ""}` | +| 논리 또는 | 297 | 35 | GC000346 `(만약(공법군 == '발파굴착A군' 또는 공법군 == '기계굴착A군', 착암공인원, 0)) * 배율` · GC000111 `만약(토질_암분류 == '호박돌 섞인 고결토・경질토' 또는 토질_암분류 == '풍화암', 로직(GC000267, 기계='020` · GC001136 `찾기(QC002058, '규격(㎜)범위'=만약(규격 == 'ø15' 또는 규격 == '20' 또는 규격 == '25' 또는 규` | +| 사칙 - | 273 | 156 | GC000998 `찾기(QC001867).시공량 * (1 - (수직고초과단계 * 9 - 경사가산) / 100)` · GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` | +| 논리 그리고 | 199 | 23 | GC001120 `만약(부위 == '뿜칠재' 그리고 뿜칠면 == '콘크리트면', 1.2, 1)` · GC000753 `찾기(QC001431, '배관규격(mm)'=배관규격, 보온재=보온재, 보온두께=보온두께).시공량 * (1 + (0 - 만약(설` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` | +| 시험입력 있음 | 195 | 0 | | +| 요소키.칸(요소 칸 값) | 160 | 49 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000138 `EQ:{장비}.주연료종류` | +| 이름 = 비목 합(노무비 · 재료비 · 경비) | 153 | 146 | GC000722 `노무비 * 기계경비율 / 100` · GC000723 `노무비 * 기계경비율 / 100` · GC000724 `노무비 * 기계경비율 / 100` | +| 비교 <= | 123 | 36 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000346 `만약(갱구거리 <= 500, 0, 찾기(CC000077, 갱구에서뚫기점까지의거리=갱구거리).할증률)` · GC000185 `만약(타설깊이 <= 25, 찾기(QC000348, L='25이하').t_1, 만약(타설깊이 <= 30, 찾기(QC000348,` | +| 함수 최대 | 122 | 43 | GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000373 `최대(찾기(CC000125, 형식=만약(형식 == '박스거더' 또는 형식 == '강바닥판 박스거더', '박스거더', '박스거더` · GC000203 `최대(만약(용도 == '신구-콘크리트 접착제바르기', 찾기(QC000412, 용도=용도, 재료명='Epoxy신구-콘크리트접착제` | +| 비교 > | 99 | 44 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 요소 부르기 ID:원문번호(+{입력}) | 84 | 14 | GC000138 `EQ:{장비}.주연료종류` · GC000139 `EQ:{장비}.주연료종류` · GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 요소 키 부르기(기계) | 78 | 37 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000140 `EQ000004.주연료종류` | +| 찾기 조건 값이 식 | 70 | 33 | GC001008 `(특별인부 * LB000003 + 보통인부 * LB000002 + 그물막인부 * LB000002) * 찾기(QC001882, ` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 함수 올림 | 67 | 53 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 호표 요소 = 자리 표시 {입력}(LB:{직종} 등) | 66 | 57 | GC000559 `LB:{공수직종}` · GC001043 `MO:휘발유:{지역}` · GC000137 `MO:경유:{지역}` | +| 함수 최소 | 51 | 18 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 빼기 부호(-x) | 47 | 28 | GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` · GC001012 `찾기(QC001889).시공량 * (1 - (-살포량증감) / 100)` | +| 비교 < | 46 | 26 | GC000561 `만약(층수층 < 6, 찾기(CC000157, '강재사용량(㎏/㎡)'=강재사용량).'보정치(a)' * 찾기(CC000159, '` · GC000564 `만약(강재총사용량 < 300, 1, 찾기(CC000165, '강재총사용량(t)'=강재총사용량, '1t당 볼트본수(본)'=볼트본` · GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` | +| 함수 반올림 | 42 | 41 | GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000088 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 재료 묶음에 {입력} | 32 | 19 | GC000264 `{"구분": "레미콘", "상세구분": "레미콘", "검색어": "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"}` · GC000431 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "싱글코아바렐 {규격}"}` · GC000432 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "더블코아바렐 {규격}"}` | +| 함수 버림 | 32 | 17 | GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` · GC000268 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계` · GC000269 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 결과 식 로직(돈 아님) | 27 | 27 | GC000422 `a * E` · GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 요소 키 부르기(재료) | 23 | 8 | GC000360 `((찾기(QC000648, 품명='아세틸렌', 레일규격=레일규격).수량) * MT003163 + (찾기(QC000648, 품명` · GC000361 `((찾기(QC000650, 품명='테르밋용재', 레일규격=레일규격, 규격='점화용').수량) * MT030628 + (찾기(Q` · GC000374 `(찾기(QC000664, 품명='용접봉').수량 * MT003191 + 찾기(QC000664, 품명='산소').수량 * 100` | +| 비교 >= | 21 | 11 | GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` · GC000774 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001452,` | +| 로직(…).칸(비목 · 계) | 14 | 11 | GC000087 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000090 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000091 `로직(GC000268, 기계='5405-0110', 지역=지역).계 * (찾기(QC000189).유압식크롤러드릴)` | +| 비교 != | 12 | 8 | GC000068 `만약(작업 != '목재 붙이기', 찾기(QC000143, 구분='보통인부', 작업=작업).수량, 0)` · GC000176 `만약(비트 != '오거비트', 해머압축기대수 * T / 60, 0)` · GC000183 `만약(재질 != '토사', T1 / T * T / 60, 0)` | +| 줄.'호표 이름'(호표 줄 금액) | 7 | 3 | GC000211 `(줄.'합판(내수 12㎜)' + 줄.'각재') * 찾기(QC000428, 사용횟수=사용횟수).소모자재 / 100` · GF000185 `(노무비 + 재료비 + 경비 - 줄.'구체콘크리트(철근) 레미콘') * 찾기(QF000387, 구분='구체콘크리트(철근) - ` · GF000210 `(줄.'패널' + 줄.'내부패널') * 찾기(QF000418, 구분=유형).요율 / 100` | +| 로직 넘길 값이 식 | 5 | 5 | GC000332 `로직(GC000267, 기계=만약(형식 == 'A-Type', '0201-0040', '0201-0020'), 지역=지역, 보` · GF000294 `로직(GC000268, 기계=만약(근원직경 < 20, '2105-0010', 만약(찾기(QF000063, '근원직경(cm)'=` · GF000297 `로직(GC000268, 기계=만약(흉고직경 < 18, '2105-0010', 만약(찾기(QF000081, '흉고직경(cm)'=` | +| (글자) 이름( 꼴(함수 아닌 이름 뒤 괄호) | 1,177 | — | GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` | +| (글자) ∼ · ~ 물결 | 63 | — | GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC001026 `찾기(QC001905, 절단폭=절단폭, 차로차단=차로차단).'굴착기+브레이커(0.2㎥~0.6㎥)' * 8 / 시공량` · GC000573 `찾기(QC001207, 구분='그라우팅펌프', 규격='40∼125(ℓ/min)').수량` | +| (글자) % 글자 | 62 | — | GC000298 `찾기(QC000519, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000299 `찾기(QC000520, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000303 `찾기(QC000524, 형식=형식).시공량 * (1 - 만약(유도블록 == '아님', 0, 찾기(QC000583, 구분='유도` | +| (글자) {} 자리 표시 | 50 | — | GC000138 `EQ:{장비}.주연료종류` · GC000138 `1 * 8 / 시공량 * EQ:{장비}.주연료 * 찾기(QC000266, 구분='주연료 할증').값 / 100` · GC000139 `EQ:{장비}.주연료종류` | +| (글자) & 글자 | 53 | — | GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` · GC000931 `(찾기(QC001700, 공정별='소운반').비계공 + 찾기(QC001700, 공정별='Draft tube설치 가설된 Conc` · GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` | +- 「요소 부르기 꼴 `보통인부(…)`」 — **문법에 없음**(이름 뒤 괄호는 함수 · 찾기 · 로직만). 글자로 1,177 번 보이는 「이름(」 은 모두 따옴표 칸 이름 안(`찾기(…).'고소작업차(5ton)'`). 요소는 키로만 부름(`LB000006` · `EQ000532.주연료2` · `EQ:{기계}.가격`). +- `&` · `∼`/`~` · `%` · `{ }` 글자 — `&` · `∼` · `%` 는 모두 따옴표 글(조건 값 · 칸 이름) 안 · `{ }` 는 `ID:원문번호{입력}` 와 재료 검색어 · 자리 표시 안(아래 ③). +- 끝수 — 1,472 중 묶음(`{대상, 자리, 방법}`) **0** · 설명 글 37 · 없음 1,435 → 엔진이 끝수를 실제로 붙이는 로직은 지금 없음(덧줄 · 함수 안 `반올림(…)` 으로 씀). +- 표 — 로직이 찾는 표 1,865 · 범위 조건이 있는 표 106(범위규칙 글 40 꼴 — 첫 낱말은 넷뿐) · 값 칸에 「1/1.30」 같은 식 글이 든 표 104(엔진이 식으로 풂). + +## ③ 흐름도로 옮기기 어려운 꼴 + +| 꼴 | 수(마디 / 로직) | 왜 어려운가 | 이렇게 옮기면 됨 | +| --- | --- | --- | --- | +| 요소 칸 값 `EQ000532.주연료2` · `EQ000098.주연료` | 160 / 49 | 요소의 값이 아니라 **요소의 다른 칸**(기계 주연료 · 가격 · 계수) | 입력 상자 「기계」 요소 알약을 칸별로(바깥 값 「기계 key + field」 — Api 에 이미 있음 `CfQuery 기계 field`) | +| `ID:원문번호{입력}` — `EQ:{기계}.가격` · `EQ:{장비}.주연료` | 84 / 14 | 부를 요소가 **입력값에 따라 바뀜**(기계경비 로직 GC000267 등 핵심 로직) | 「재료 자리」 처럼 「기계 자리」(선택 자리 · 입력 알약 값으로 키 고름) — 지금 흐름도에 없음 → **새 입력 방식 필요(⬜ 브레인)** · 그 전엔 「다른 로직 입력」 으로 기계경비 로직을 통째로 부름 | +| 호표 요소 자리 표시 `LB:{공수직종}` · `MO:경유:{지역}` | 66 / 57 | 노임 직종 · 유가 지역이 입력값에 따라 바뀜 | 같은 「선택 자리」(인력 · 유가전력) — 재료 방식 셋을 다른 그룹에도 | +| 재료 묶음 검색어에 `{입력}` — `레미콘 {굵은골재최대치수}-{강도}-{슬럼프}` | 32 / 19 | 검색어가 입력값으로 만들어짐 | 재료 「조건 찾기」(이름 / 규격에 알약 값) — 알약 여럿을 이어 붙인 글이 필요 → 함수 상자로 글을 만들어 조건 알약으로 | +| 호표 로직 줄(비목 없음) `로직(GC000267, 기계='0201-0020', 지역=지역)` | 1,075 / 506 | 한 줄 금액이 **노무비 · 재료비 · 경비 셋으로 나뉨** | 다른 로직 입력 상자의 출력 알약 셋(노무비 · 재료비 · 경비) × 수량 함수 셋 → 출력 셋 | +| 로직 넘길 값이 식 `기계=만약(형식 == 'A-Type', '0201-0040', …)` | 5 / 5 | 넘길 값이 조건으로 바뀜 | 함수 상자(IF)로 값을 만들어 다른 로직의 받는 알약에 선 | +| 찾기 조건 값이 식 `'시공높이(m)'=최소(시공높이m, 7.2)` | 70 / 33 | 조건 값이 계산값 | 함수 상자로 먼저 셈 → 「표에서 찾기」 조건 알약에 그 출력 | +| 덧줄의 비목 합 `노무비 * 기계경비율 / 100` | 153 / 146 | 바탕이 **앞 줄까지의 비목 합**(덧줄이 덧줄을 바탕으로 쓸 수 있음) | 출력 상자 비목별 모음 함수(앞 줄 차례대로) → 비율 함수 · 앞 덧줄도 받는 알약으로 | +| 호표 줄 금액 `줄.'합판(내수 12㎜)'` | 7 / 3 | 특정 호표 줄 금액을 다시 씀 | 그 줄 금액 함수 출력에서 선 | +| 로직 결과 칸 `로직(…).계` | 14 / 11 | 다른 로직 돈 결과의 한 칸 | 다른 로직 입력의 그 비목 · 계 알약 | +| 표 한 줄(조건 없는 찾기) `찾기(QC001864).시공량` | 1,022 / 286 | 조건이 없어 「줄 하나」 표 | 「표에서 찾기」 조건 없이 칸만(표 첫 줄) — 줄이 둘 이상이면 옛 엔진도 오류 | +| 범위 조건 표(범위규칙 넷 · 위 끝 최소) | 표 106 | 엑셀 한 식으로 쓰기 어려움(빈 칸 = 무엇이든 · 규칙별 경계) | 선 규칙 「표에서 찾기」 가 엔진 `find` 와 같은 뜻으로(줄마다 맞음 칸 + INDEX/MATCH — 옛 가져오기에서 쓴 꼴) | +| 표 값의 식 글 「1/1.30」 | 표 104 | 엔진은 글을 식으로 풂 · 엑셀은 글 | 서버가 표 블록을 줄 때 수로 바꿔 줌 | +| 결과 식 로직(돈 아님) | 27 / 27 | 출력이 비용 아님 | 출력 종류 「값」 | +| 빈 입력 · 표에 없는 조건 | — | 엔진은 **그 줄만 비우고** 계속(합에서 뺌) | 흐름도 「끊긴 곳」 + 그 출력 0 으로 셈(같은 합) | + +## ④ 계산 길(로직 셈)을 부르는 곳 + +| 어디 | 무엇 | 자리 | +| --- | --- | --- | +| 서버 `POST /api/m01/calc` | 시험 계산(화면 · 저장 전 줄 포함) | `M01_MasterData_Router.py:222` → `Store.calc` :543 | +| 서버 `POST /api/m01/text` | 읽는 식 줄(텍스트 수식) | `M01_MasterData_Router.py:228` → `Store.text` :572 | +| 서버 `GET /api/m01/logic/auto` | 견본 입력값 | `M01_MasterData_Router.py:192` → `Store.auto` :563 → `master_auto.values` :80 | +| 화면 M01 단가산출 시험 계산 | `/calc` · `/text` · `/logic/auto` | `M01_MasterData_UI_Logic_Api.ts:224 · :229 · :245` · 부름 `M01_MasterData_UI_Logic_Calc.ts:142 · :155 · :184` | +| 서버 일위대가 조합 미리보기 | 조합에 담긴 로직마다 셈 | `M01_MasterData_Store_Combo.py:259 combo_preview` → `master_combo.py:106 run`(:128 `mf.run`) · M02 `POST /api/m02/layers/{layer}/combos/preview` `M02_MasterTemplete_Router_Combos.py:195` → `M02_MasterTemplete_Combo_Store.py:193` | +| 서버 프로젝트 사본 계산 | 프로젝트 항목 단가(복사본 표 · 노임) | `POST /api/m02/projects/{id}/items/{folder}/calc` `M02_Project_Items_Router.py:178` → `M02_Item_Copy.py:326` → `M02_Item_Prices.py:211`(:230 `mf.run`) · 화면 `ui_template/ui_template_item_picker_project.ts:218` | +| 흐름도 서버 | 단가 · 다른 로직 값(호표 한 줄 견본 로직으로 옛 셈 부름) | `M02_DF_Values.py:68 · :105 · :229 · :262`(`store.calc`) · `M02_DF_Router.py:216 logic_value`(흐름도 셈 `M02_DF_Compute`) | +| 옛 시험판 서버(CostFlow · 남김) | 같은 꼴 | `M02_CF_Router.py:230 · :267 · :390 · :421` | +| 일괄 · 대조 스크립트 | 전 로직 시험 계산 · 견적 대조 | `check_master.py:511 calc_all` · `compare_estx.py:258 · :456` · `master_text.py:504` | + +갈래(정함 4)를 넣을 곳 = **`Store.calc` · `Store.text` 둘**(M01 화면 · 흐름도 값이 모두 여기를 지남) + 조합 미리보기 `master_combo.run` · 프로젝트 `M02_Item_Prices.calc`(이 둘은 `mf.run` 을 바로 부름 — 흐름이 있는 로직을 거르려면 여기도). + +## ⑤ 시험입력 · 기본 입력 + +- 줄에 `시험입력` 이 있는 로직 **195**(1,472 중) — 일부 입력만 적힘(예 GF000219 `{"높이": 4.0, "돌": "깬잡석"}`). +- 기본 입력 만드는 법이 **둘**이고 범위에서 갈림: + - 일괄 시험(`check_master.py:496 시험입력`) — 고르기 첫 값 · 범위 **위 끝** · 그 밖 1 · 줄의 `시험입력` 이 이김. + - 화면 견본(`master_auto.py:80 values` · `/logic/auto`) — 고르기 첫 값 · 범위 **아래 끝** · 그 밖 = 그 입력으로 찾는 표의 가장 낮은 값(없으면 나누는 수면 1 · 아니면 0) · 단위에 「원」 이면 비움. +- 전수 대조(정함 3)는 **`check_master.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름). From dcb023f4e6362625399215ae0059ee98e54c9c3d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:37:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=ED=8C=90=20=EC=8B=9D=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=B9=B8=20=EC=97=B0=EA=B2=B0=20=ED=91=9C=20=C2=B7?= =?UTF-8?q?=20=EC=B9=B8=20=EC=83=89=20=C2=B7=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=97=B0=EA=B2=B0=20+=20?= =?UTF-8?q?=EC=84=A0=20=EC=A1=B0=EA=B1=B4=20=EC=97=AC=EB=9F=AC=20=EC=A4=84?= =?UTF-8?q?(=EA=B7=B8=EB=A6=AC=EA=B3=A0/=EB=98=90=EB=8A=94=20=C2=B7=20?= =?UTF-8?q?=EA=B0=92=20=EB=AA=A9=EB=A1=9D=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B2=94=EC=9C=84=20=EA=B2=80=EC=82=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- .../DataFlow/M02_DF_CellTable.ts | 135 +++++++++++++++ .../DataFlow/M02_DF_FuncExtra.ts | 85 ++++++++++ .../DataFlow/M02_DF_FuncPanel.css | 117 ++++++++++++- .../DataFlow/M02_DF_FuncPanel.ts | 39 ++++- .../DataFlow/M02_DF_LinkPanel.ts | 158 +++++++++++++++--- 5 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts new file mode 100644 index 000000000..1e8eac043 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts @@ -0,0 +1,135 @@ +/* ============================================================================= + * M02_DF_CellTable.ts + * 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기 + * 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfBox, CfLogic } from "./M02_DF_Model"; +import { portText } from "./M02_DF_Detail"; + +const TEXT = { + Title: ["칸 연결 표", "Cell connections"], + Cell: ["칸", "Cell"], + Pill: ["연결 알약", "Pill"], + Kind: ["종류", "Kind"], + Line: ["이어진 선", "Line"], + In: ["입력", "Input"], + Var: ["변수", "Variable"], + Out: ["결과", "Result"], + None: ["아직 연결된 칸이 없음", "No cell is connected yet"], + Unplaced: ["칸 없음", "no cell"], + NoLine: ["-", "-"], + Release: ["풀기", "Release"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], +} as const; + +const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +export type CellKind = "in" | "var" | "out"; + +export interface CellTableHandlers { + /** 표에서 칸을 바꿈 — key = 알약 이름 */ + move(kind: CellKind, key: string, cell: string): void; + /** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */ + release(kind: CellKind, key: string): void; +} + +interface Row { + kind: CellKind; + key: string; + cell: string; + line: string; +} + +function rowsOf(lg: CfLogic, box: CfBox): Row[] { + const f = box.func; + if (!f) return []; + const into = (port: string): string => { + const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port); + return l ? portText(lg, l.from) : tt("NoLine"); + }; + const out = (): string => { + const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name); + return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine"); + }; + return [ + ...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })), + ...f.variables.map((v): Row => ({ + kind: "var", + key: v.name, + cell: v.cell ?? "", + line: tt("NoLine"), + })), + { kind: "out", key: f.output.name, cell: f.output.cell, line: out() }, + ]; +} + +/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */ +export function renderCellTable( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + readOnly: boolean, + handlers: CellTableHandlers, +): void { + host.classList.add("m02-dfcells"); + const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key); + const head = el("tr", { + children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) => + el("th", { text: t }), + ), + }); + const body = rows.map((r) => { + const input = el("input", { + className: "ui-input ui-input--sm m02-dfcells__cell", + attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") }, + }); + input.value = r.cell; + input.readOnly = readOnly; + input.addEventListener("change", () => { + const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tt("BadCell"); + if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`); + }); + return el("tr", { + className: `m02-dfcells__row is-${r.kind}`, + children: [ + el("td", { children: [input] }), + el("td", { text: r.key }), + el("td", { + children: [ + el("span", { + className: "m02-dfcells__kind", + text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"), + }), + ], + }), + el("td", { text: r.line }), + el("td", { + children: + readOnly || (r.kind !== "var" && !r.cell) + ? [] + : [ + createButton({ + label: tt("Release"), + variant: "ghost", + size: "sm", + onClick: () => handlers.release(r.kind, r.key), + }), + ], + }), + ], + }); + }); + host.replaceChildren( + el("strong", { className: "m02-dfcells__title", text: tt("Title") }), + el("table", { + className: "m02-dfcells__table", + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts new file mode 100644 index 000000000..6d302fea5 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * M02_DF_FuncExtra.ts + * 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것 + * 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { showToast } from "@ui/ui_template_elements"; +import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition"; +import { renderCellTable } from "./M02_DF_CellTable"; +import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; +import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView"; +import { TEXT } from "./M02_DF_FuncText"; +import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model"; + +export interface ExtraContext { + lg: CfLogic; + boxId: string; + detail: DetailOptions; + readOnly: boolean; + place(port: string, cell: string): void; + setOutput(cell: string): void; + patch(make: (f: CfFunc) => CfFunc): void; + used(f: CfFunc): Map; +} + +const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0]; +const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId); + +/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */ +export function drawFormula(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + const ev = evalSafe(x.lg, x.detail); + renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId]))); +} + +/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */ +export function drawCells(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + renderCellTable(host, x.lg, box, x.readOnly, { + move: (kind, key, cell) => { + if (kind === "in") return x.place(key, cell); + if (kind === "out") return x.setOutput(cell); + if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error"); + x.patch((f) => ({ + ...f, + variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)), + })); + }, + release: (kind, key) => + x.patch((f) => + kind === "in" + ? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) } + : kind === "var" + ? { ...f, variables: f.variables.filter((v) => v.name !== key) } + : { ...f, output: { ...f.output, cell: "" } }, + ), + }); +} + +/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */ +export function conditionsOf(x: ExtraContext): ConditionOptions { + return { + valuesFor: (a1) => { + const f = boxOf(x)?.func; + const at = a1.toUpperCase(); + const v = f?.variables.find((y) => y.cell?.toUpperCase() === at); + if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; + const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port; + const link = port + ? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port) + : undefined; + return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null; + }, + pills: () => { + const f = boxOf(x)?.func; + return [ + ...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })), + ...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })), + ]; + }, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css index 7497c4a10..77a5471f1 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel-chip.is-out { - border-color: #f59e0b; + border-color: #27ae60; } .m02-dfpanel-chip.is-var { - border-color: #22a06b; + border-color: #d99a00; } /* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ @@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel .aislo-cell[data-cf-kind="var"] { - box-shadow: inset 0 0 0 2px #22a06b; + box-shadow: inset 0 0 0 2px #d99a00; } .m02-dfpanel .aislo-cell[data-cf-kind="out"] { - box-shadow: inset 0 0 0 2px #f59e0b; + box-shadow: inset 0 0 0 2px #27ae60; } .m02-dfpanel .aislo-cell[data-cf-kind]::after { @@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible { outline: 2px dashed var(--color-danger); outline-offset: -2px; } + +/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */ +.m02-dfpanel-body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-12); +} + +.m02-dfpanel-body > .m02-dfpanel-sheet { + flex: 1 1 28em; + min-width: 0; +} + +.m02-dfcells { + flex: 0 1 24em; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-dfcells__title { + font-weight: var(--font-weight-bold); +} + +.m02-dfcells__table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m02-dfcells__table th, +.m02-dfcells__table td { + padding: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m02-dfcells__cell { + width: 5em; +} + +.m02-dfcells .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m02-dfcells__kind { + padding: 0 var(--spacing-8); + border: 2px solid; + border-radius: var(--radius-pills); + white-space: nowrap; +} + +.m02-dfcells__row.is-in .m02-dfcells__kind { + border-color: #3b82f6; +} + +.m02-dfcells__row.is-var .m02-dfcells__kind { + border-color: #d99a00; +} + +.m02-dfcells__row.is-out .m02-dfcells__kind { + border-color: #27ae60; +} + +/* 식 보기 */ +.m02-df-formula { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m02-df-formula__title { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__line { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: var(--spacing-4); + border-top: 1px dashed var(--color-border); +} + +.m02-df-formula__valued { + color: var(--color-text-secondary); +} + +.m02-df-formula__branches { + margin: 0; + padding-left: var(--spacing-16); + list-style: none; +} + +.m02-df-formula__branches .is-now { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__empty { + margin: 0; + color: var(--color-text-muted); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index 45e3b2be5..17f9cb13a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -14,6 +14,8 @@ import type { Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; +import type { DetailOptions } from "./M02_DF_Detail"; +import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra"; import { createRoundRow } from "./M02_DF_RoundRow"; import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; @@ -37,6 +39,8 @@ interface State { lg: CfLogic; on: Handlers; readOnly: boolean; + /** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ + detail: DetailOptions; handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; @@ -48,6 +52,8 @@ interface State { suggest: number | null; parts: { head: HTMLElement; + formula: HTMLElement; + cells: HTMLElement; got: HTMLElement; vars: HTMLElement; bar: HTMLElement; @@ -201,6 +207,20 @@ function problems(st: State): string[] { return out; } +/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */ + +/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */ +const extra = (st: State): ExtraContext => ({ + lg: st.lg, + boxId: st.boxId, + detail: st.detail, + readOnly: st.readOnly, + place: (port, cell) => placePort(st, port, cell), + setOutput: (cell) => setOutput(st, cell), + patch: (make) => patchFunc(st, make), + used: (f) => usedCells(f), +}); + /* ------------------------------ 칸 위 표시 ------------------------------ */ function applyMarks(st: State): void { @@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise { preview: true, tabs: false, readOnly: st.readOnly, + conditions: conditionsOf(extra(st)), onChange: (b) => onBook(st, b), onSelect: (s) => { st.cell = s.칸; @@ -593,13 +614,14 @@ export function renderFuncPanel( lg: CfLogic, boxId: string, on: Handlers, - opts: { readOnly?: boolean } = {}, + opts: DetailOptions & { readOnly?: boolean } = {}, ): void { const old = states.get(host); const readOnly = !!opts.readOnly; if (old && old.boxId === boxId && old.readOnly === readOnly) { old.lg = lg; old.on = on; + old.detail = opts; const f = funcOf(old); if (old.handle && f && sheetJson(f) !== old.json) { old.json = sheetJson(f); @@ -615,6 +637,8 @@ export function renderFuncPanel( } const parts: State["parts"] = { head: el("div", { className: "m02-dfpanel-head" }), + formula: el("div"), + cells: el("div"), got: el("div", { className: "m02-dfpanel-row" }), vars: el("div", { className: "m02-dfpanel-vars" }), bar: el("div", { className: "m02-dfpanel-bar" }), @@ -626,6 +650,7 @@ export function renderFuncPanel( lg, on, readOnly, + detail: opts, handle: null, snap: {}, json: "", @@ -639,12 +664,22 @@ export function renderFuncPanel( drawGot(st); drawVars(st); drawBar(st); + drawFormula(st.parts.formula, extra(st)); + drawCells(st.parts.cells, extra(st)); applyMarks(st); }, }; states.set(host, st); host.classList.add("m02-dfpanel"); - host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + host.replaceChildren( + parts.head, + parts.formula, + parts.got, + parts.vars, + parts.bar, + parts.msg, + el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }), + ); parts.msg.hidden = true; st.redraw(); void mountSheet(st); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts index 5f08dcf38..f8efa089d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -9,8 +9,12 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { + gateConds, + makeGate, portsOut, reaches, + valueChoices, + type CfGateCond, type CfGateOp, type CfLogic, type CfPort, @@ -77,6 +81,12 @@ const TEXT = { ], WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"], Count: ["선", "Line"], + And: ["그리고", "and"], + Or: ["또는", "or"], + If: ["조건", "If"], + AddLine: ["+ 조건 줄", "+ Condition line"], + Join: ["잇는 말", "Join with"], + OutRange: ["범위 {n} 밖", "Outside the range {n}"], Range: ["{n} (범위)", "{n} (range)"], } as const; @@ -92,6 +102,7 @@ interface Ctx { linkId: string; readOnly: boolean; headers: string[]; + detail: DetailOptions; change(lg: CfLogic): void; } @@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract): HTML return out; } +/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */ +function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null { + const v = ctx.lg.boxes + .find((b) => b.id === p.box) + ?.input?.variables.find((x) => x.name === p.name); + return v?.type === "number" && v.range ? v.range : null; +} + +/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */ +function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] { + const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values }); + if (list?.length) { + const key = (v: string | number): string => `${typeof v}:${v}`; + const options = list.map((v) => ({ value: key(v), text: String(v) })); + return [ + select( + ctx, + options, + key(c.value), + (k) => { + const hit = list.find((v) => key(v) === k); + if (hit !== undefined) onPick(hit); + }, + tk("Value"), + ), + ]; + } + const range = rangeOf(ctx, c.from); + const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))]; + const n = Number(c.value); + if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) { + out.push( + el("span", { + className: "m02-dflink-warn", + text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`, + }), + ); + } + return out; +} + function gateEditor(ctx: Ctx, rule: Extract): HTMLElement[] { - const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); - return [ - el("p", { className: "m02-dflink-note", text: tk("GateHint") }), - row( - select( - ctx, - pillOptions(ctx), - toKey(rule.from), - (v) => put({ from: fromKey(v) }), - tk("Match"), + const g = gateConds(rule); + const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join)); + const edit = (i: number, patch: Partial): void => + put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c))); + const out: HTMLElement[] = [el("p", { className: "m02-dflink-note", text: tk("GateHint") })]; + g.conds.forEach((c, i) => { + out.push( + row( + el("span", { + className: "m02-dflink-label", + text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"), + }), + select( + ctx, + pillOptions(ctx), + toKey(c.from), + (v) => { + const from = fromKey(v); + edit(i, { + from, + value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0, + }); + }, + tk("Match"), + ), + select( + ctx, + GATE_OPS.map((o) => ({ value: o, text: o })), + c.op, + (v) => edit(i, { op: v as CfGateOp }), + tk("Kinds"), + ), + ...gateValue(ctx, c, (v) => edit(i, { value: v })), + ctx.readOnly || g.conds.length < 2 + ? "" + : createButton({ + label: tk("Remove"), + variant: "ghost", + size: "sm", + onClick: () => put(g.conds.filter((_, j) => j !== i)), + }), ), - select( - ctx, - GATE_OPS.map((o) => ({ value: o, text: o })), - rule.op, - (v) => put({ op: v as CfGateOp }), - tk("Kinds"), + ); + }); + if (!ctx.readOnly) { + const first = g.conds[0]; + out.push( + row( + createButton({ + label: tk("AddLine"), + variant: "ghost", + size: "sm", + onClick: () => + put([...g.conds, { from: first?.from ?? { box: "", name: "" }, op: "=", value: 0 }]), + }), + ...(g.conds.length > 1 + ? [ + el("span", { className: "m02-dflink-label", text: tk("Join") }), + select( + ctx, + [ + { value: "그리고", text: tk("And") }, + { value: "또는", text: tk("Or") }, + ], + g.join, + (v) => put(g.conds, v as "그리고" | "또는"), + tk("Join"), + ), + ] + : []), ), - numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")), - ), - ]; + ); + } + return out; } function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { @@ -323,7 +428,7 @@ function roundEditor( const DEFAULT: Record, (from: CfPort) => CfRule> = { lookup: () => ({ kind: "lookup", conds: [], col: "" }), - gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }), + gate: (from) => makeGate([{ from, op: "≥", value: 0 }]), scale: () => ({ kind: "scale", op: "×", value: 1 }), round: () => ({ kind: "round", places: 0, method: "반올림" }), }; @@ -345,14 +450,23 @@ export function renderLinkPanel( Array.isArray(table) && Array.isArray(table[0]) ? table[0].map((h) => String(h ?? "")).filter(Boolean) : []; - const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change }; + const ctx: Ctx = { + lg, + linkId, + readOnly: !!opts.readOnly, + headers, + detail: opts, + change: on.change, + }; const rule = link.rule; const kind: Kind = rule?.kind ?? "none"; const parts: HTMLElement[] = [ el("div", { className: "m02-dflink-head", children: [ - el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }), + el("strong", { + text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`, + }), el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }), ], }),