From 7ad2f2c133669aa6f1fedccfaa90d558d21495a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:24:18 +0900 Subject: [PATCH 1/7] =?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/7] =?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/7] =?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/7] =?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)})` }), ], }), From deabd978c00e4bc1c2362cbf7a7875a16ee38130 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:39:21 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=B2=98=EC=9D=8C=20=EB=B0=B0=EC=9C=A8=20?= =?UTF-8?q?=3D=20=EC=9D=BD=ED=9E=88=EB=8A=94=20=EC=B5=9C=EC=86=8C=200.6(?= =?UTF-8?q?=EB=84=98=EC=B9=98=EB=A9=B4=20=EC=99=BC=EC=AA=BD=20=EC=9C=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=83=81=EC=9E=90=EB=B6=80=ED=84=B0)=20?= =?UTF-8?q?=C2=B7=20=E3=80=8C=ED=95=9C=EB=88=88=EC=97=90=E3=80=8D=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=EB=8A=94=20=EC=A0=84=EC=B2=B4(0.25)=20=C2=B7?= =?UTF-8?q?=20=ED=81=AC=EA=B2=8C=20=EB=B3=B4=EA=B8=B0=EB=8A=94=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=200.9(PLAN=2036-3=20=EB=8D=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다이어그램 opts.fitAllMin(단추) · fitMin(저절로) 나눔 · Paper · Panel 이 넘김 - 시험 diagram_parts 8 + panel 2 통과 · DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 15 ++++++++++++--- .../DataFlow/M02_DF_Diagram_Parts.ts | 2 ++ M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 9 ++++++--- M02_MasterTemplete/DataFlow/M02_DF_Paper.ts | 3 +++ resources/tester/test_m02_df_diagram_parts.py | 17 ++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index ae84fe5e1..26dff53d5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -314,13 +314,22 @@ export function renderDiagram( applyView(); drawLines(); }; - const fit = (): void => { + /** 맞추기 — 저절로(처음 · 다시 맞춤)는 fitMin · 「한눈에」 단추는 fitAllMin(전체가 보이게) */ + const fit = (button = false): void => { const rects = lg.boxes.map((b) => { const n = world.querySelector(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`); return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; }); const r = view.getBoundingClientRect(); - Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin)); + Object.assign( + st, + fitView( + rects, + { w: r.width, h: r.height }, + 40, + button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin, + ), + ); applyView(); drawLines(); }; @@ -400,7 +409,7 @@ export function renderDiagram( sm("ZoomOut", () => setZoom(st.zoom / 1.2)), zoomLabel, sm("ZoomIn", () => setZoom(st.zoom * 1.2)), - sm("Fit", fit), + sm("Fit", () => fit(true)), ...(ro ? [] : [ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 49052241f..a81da8fbc 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -124,6 +124,8 @@ export interface DiagramOpts { fitKey?: number; /** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */ fitMin?: number; + /** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */ + fitAllMin?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index de5d49f87..173a32cd0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -52,8 +52,10 @@ export interface FlowPanel { export const PANEL_MIN = 320; export const STRIP_WIDTH = 40; -/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */ -export const PANEL_FIT_MIN = 0.25; +/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */ +export const PANEL_FIT_MIN = 0.6; +/** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */ +export const PANEL_FIT_ALL_MIN = 0.25; const WIDTH_KEY = "m02.dfpanel.width"; const FOLD_KEY = "m02.dfpanel.folded"; @@ -94,8 +96,9 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane readOnly: opts.readOnly, onChange: opts.onChange, logics: opts.logics, - // 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림 + // 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90% fitMin: () => (full ? 0.9 : PANEL_FIT_MIN), + fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN), }); const { diagram, detail, summary, note, undo } = paper.parts; let width = storedWidth(readStore(WIDTH_KEY)); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts index 85146e7f4..228f3debf 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -55,6 +55,8 @@ export interface PaperOpts { logics?(key: string): Promise; /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ fitMin?(): number; + /** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */ + fitAllMin?(): number; } export interface Paper { @@ -307,6 +309,7 @@ export function createPaper(opts: PaperOpts): Paper { readOnly, fitKey, fitMin: opts.fitMin?.(), + fitAllMin: opts.fitAllMin?.(), }); drawPanel(); renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); diff --git a/resources/tester/test_m02_df_diagram_parts.py b/resources/tester/test_m02_df_diagram_parts.py index ae8248233..00e7301e8 100644 --- a/resources/tester/test_m02_df_diagram_parts.py +++ b/resources/tester/test_m02_df_diagram_parts.py @@ -43,7 +43,7 @@ const out = jobs.map((job) => { if (job.op === "deps") return m.depLines(job.lg).map((d) => ({ id: d.id, from: d.from, to: d.to })); if (job.op === "inTrace") return m.depLines(job.lg).map((d) => m.depInTrace(d, job.steps)); if (job.op === "rest") return job.breaks.map((b) => m.isRest(b)); - if (job.op === "fit") return m.fitView(job.rects, job.view); + if (job.op === "fit") return m.fitView(job.rects, job.view, 40, job.min); if (job.op === "value") return job.values.map((v) => m.showValue(v)); if (job.op === "end") return m.depEnd(job.a, job.tag); if (job.op === "restParts") { @@ -404,3 +404,18 @@ def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나(): assert got["boxes"] == ["brk"] assert got["links"] == ["l2", "l6"] assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"] + + +def test_패널_처음_배율은_읽히는_최소_넘치면_왼쪽_위부터(): + rects = [{"x": 40, "y": 40, "w": 200, "h": 120}, {"x": 1500, "y": 600, "w": 200, "h": 120}] + auto, whole = run( + [ + {"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.6}, + {"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.25}, + ] + ) + # 처음 — 0.6 아래로 안 내림 · 넘치면 왼쪽 위(첫 상자 - 여백)가 판 왼쪽 위 + assert auto["zoom"] == 0.6 + assert auto["panX"] == 0 and auto["panY"] == 0 + # 한눈에 — 전체가 들어가게 더 작게 + assert whole["zoom"] < 0.6 From 3ea0c0fdf24a5d96185c76e8a696559ba4059525 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:40:53 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor(m01):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=99=BC=EC=AA=BD=20=EB=B3=B4=EA=B8=B0=20=E2=80=94=20=EC=8B=9D?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=EB=A5=BC=20sub2=20formulaLines=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20import=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M01_MasterData_UI_LogicLab_FlowView.ts | 53 +++++++------------ 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts index 87b9d9de2..cdf1e7456 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_FlowView.ts * 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다 - * 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠. + * 식 두 줄 — sub2 `formulaLines`). 기본정보 · 시험 계산은 Detail 이 둠. * 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름. * 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름. * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8. @@ -15,7 +15,12 @@ import { type CfEvalResult, type CfValues, } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; -import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { + cellValuesOf, + formulaLines, + type FormulaLine, +} from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView"; +import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; import { flowRows, @@ -51,22 +56,6 @@ const GROUP_TITLE: Record = { plain: "Plain", }; -/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */ -interface FormulaLine { - name: string; - named: string; - valued: string; - branches?: { when: string; then: string; now: boolean }[]; -} -type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[]; -const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>( - "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", -); -const loadFormula = async (): Promise => { - const load = Object.values(FORMULA)[0]; - return load ? ((await load()).formulaLines ?? null) : null; -}; - const section = (title: string, body: HTMLElement[]): HTMLElement => el("section", { className: "m01-logic__section m01lab__box", @@ -182,26 +171,20 @@ export function buildFlowView( el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), ]); - const bodies = rows.funcs.map((f) => funcBlock(f, null)); + /** 함수 상자마다 식 두 줄(sub2 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */ + const linesOf = (id: string): FormulaLine[] | null => { + const box = lg.boxes.find((b) => b.id === id); + if (!box?.func) return null; + try { + return formulaLines(box, cellValuesOf(box, ev.arrived?.[id])); + } catch { + return null; + } + }; + const bodies = rows.funcs.map((f) => funcBlock(f, linesOf(f.box))); const text = section( tf("Formula"), bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })], ); host.replaceChildren(detail, text); - // sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함 - void loadFormula().then((fn) => { - if (!fn || !bodies.length) return; - const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func); - const merged = { ...ev.ports, ...ev.arrived } as CfValues; - text.replaceChildren( - text.firstElementChild!, - ...rows.funcs.map((f, i) => { - try { - return funcBlock(f, fn(boxes[i], merged)); - } catch { - return bodies[i]; - } - }), - ); - }); } From 7b90d28e2274df80e58e0d83f044dfedb4bbd2d7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:41:32 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20=E3=80=8C?= =?UTF-8?q?=ED=9D=90=EB=A6=84=E3=80=8D=20=EC=B9=B8=20=E2=80=94=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=C2=B7=20=EA=B3=84=EC=82=B0=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EC=8B=A0=EA=B7=9C=20GX=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EB=A1=9C=EC=A7=81=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=ED=82=A4(PLAN=2036-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /save 줄의 흐름 = 꼴 검사(없는 알약 · 겹친 선 · 돌고 도는 선 422 · 빈 받는 자리 경고) → M02_DF_Compute 로 다시 셈 → 받는 칸 값 - 키 없는 새 흐름 줄 = 로직_자체.json 에 GX 다음 키 · 구분 자체 · 옛 식 칸은 빈 것 · 응답 {files, added, warnings} - GET /api/m01/logic/flow = 흐름 그대로 · 없으면 M02_DF_Convert.convert · 아직 없으면 빈 흐름 + 변환 준비 중 - /calc = 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · sums 노무비 · 재료비 · 경비 · 계 · outputs) · 없으면 옛 셈 - DataFlow 다른 로직 입력 · /logic-value · /design-inputs 가 마스터 키도(흐름 = 흐름 셈 · 옛 로직 = 옛 셈) · 서로 부르면 막음 - Compute 의 Router _num 은 모듈로 부름(서로 부름 풀기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/M01_MasterData_Router.py | 14 +- M01_MasterData/M01_MasterData_Store_Flow.py | 269 ++++++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Compute.py | 14 +- M02_MasterTemplete/DataFlow/M02_DF_Router.py | 14 +- resources/tester/test_m01_logic_flow_save.py | 240 ++++++++++++++++ 5 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Flow.py create mode 100644 resources/tester/test_m01_logic_flow_save.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 31501f7f8..de50a83e3 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -14,6 +14,7 @@ from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"]) @@ -221,6 +222,10 @@ def get_materials( @router.post("/calc") def post_calc(body: CalcBody) -> dict: + """줄에 「흐름」 이 있으면 흐름 셈(입력 = 설계 때 정할 것) · 없으면 옛 셈(정함 4).""" + got = _call(flow.calc, body.key, body.inputs, body.row) + if got is not None: + return got with store.cm.mf.mp.choosing(body.고름): return _call(store.calc, body.key, body.inputs, body.row, body.file) @@ -234,7 +239,14 @@ def post_text(body: CalcBody) -> dict: @router.post("/save") def post_save(body: SaveBody) -> dict: - return {"files": _call(store.save, [f.model_dump() for f in body.files])} + """「흐름」 줄은 검사 · 서버 다시 셈 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}.""" + return _call(flow.save, [f.model_dump() for f in body.files]) + + +@router.get("/logic/flow") +def get_logic_flow(key: str, file: str = "") -> dict: + """흐름 받기 — {flow, converted, notes} · 흐름이 있으면 그대로 · 없으면 변환(파일 안 바뀜).""" + return _call(flow.get_flow, file, key) @router.post("/logic/new") diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py new file mode 100644 index 000000000..eb2d329f7 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Flow.py @@ -0,0 +1,269 @@ +"""M01 로직 「흐름」 칸(PLAN 36-4 · 정함 1 · 2 · 4 · 10 · 11 · 12). + +정함 = `docs/raw/M01_쉬운단가/흐름도_적용_정함.md`. + +· 흐름 = DataFlow Model `CfLogic` 꼴 그대로 · 로직 줄 칸 「흐름」 · 있으면 흐름이 정본 +· 쓰는 때 = `/save` 줄에 실어 온 것뿐(자동 변환 결과는 안 씀) — 꼴 검사(상자 · 선 · 돌고 도는 선) → + 서버 셈(`M02_DF_Compute.run`)으로 다시 셈 → 받는 칸에 값만 되돌려 넣음 · 빈 받는 자리는 경고 +· 새 줄(키 없음) + 흐름 = 자체 파일(`로직_자체.json`)에 더함 → 키 = 대장 `GX` 다음 · 구분 자체 +· 계산 = 흐름이 있으면 흐름 셈 · 없으면 옛 셈(`Store.calc`) · 입력 이름 = 「설계 때 정할 것」 +· 다른 로직 입력 = 마스터 키도 — 흐름 로직은 흐름 셈 · 옛 로직은 옛 셈(노무비 · 재료비 · 경비 · 계) +· 흐름 받기 = 줄의 흐름 그대로 · 없으면 sub7 변환(`M02_DF_Convert.convert`) · 아직 없으면 빈 흐름 +권한 = `/api/m01` 전체가 시스템 관리자(`main.py`) — 공용 로직 흐름도 관리자가 고침. +""" + +from __future__ import annotations + +import importlib +from decimal import Decimal, InvalidOperation +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Make as make +from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc + +FLOW = "흐름" +KINDS = ("input", "function", "output") +CONVERT = "M02_MasterTemplete.DataFlow.M02_DF_Convert" +NOT_READY = "변환 준비 중" + + +# ── 검사(Model `link` · `ruleWhy` 와 같은 뜻) ────────────────────────────── +def _outs(box: dict) -> set: + """내보내는 알약 — Model `portsOut`.""" + if box.get("kind") == "function": + return {((box.get("func") or {}).get("output") or {}).get("name")} + inp = box.get("input") or {} + if box.get("kind") != "input": + return set() + if inp.get("mode") == "logic": + return {p.get("name") for p in (inp.get("logic") or {}).get("outputs") or []} + out = {dc._port(e) for e in inp.get("elements") or []} if inp.get("mode") == "group" else set() + if inp.get("mode") == "group" and inp.get("table"): + out.add(dc.TABLE_PORT) + return out | {v.get("name") for v in inp.get("variables") or []} + + +def _ins(box: dict) -> set: + """받는 알약 — Model `portsIn`.""" + if box.get("kind") == "function": + return {p.get("port") for p in (box.get("func") or {}).get("inputs") or []} + if box.get("kind") == "output": + return {dc.OUTPUT_PORT} + inp = box.get("input") or {} + return {p.get("name") for p in (inp.get("logic") or {}).get("designs") or []} + + +def check(flow: Any) -> list[str]: + """흐름 꼴 검사 — 틀리면 422 · 돌려받는 것 = 경고(빈 받는 자리).""" + if not isinstance(flow, dict) or not isinstance(flow.get("boxes"), list): + raise store.StoreError(422, {"errors": ["흐름 꼴이 아님 — 상자(boxes) 목록이 없음"]}) + if not isinstance(flow.get("links"), list): + raise store.StoreError(422, {"errors": ["흐름 꼴이 아님 — 선(links) 목록이 없음"]}) + errors, boxes = [], {} + for b in flow["boxes"]: + if not isinstance(b, dict) or not b.get("id"): + errors.append("번호(id) 없는 상자가 있음") + continue + name = b.get("name") or b["id"] + if b["id"] in boxes: + errors.append(f"상자 번호가 겹침 「{b['id']}」") + boxes[b["id"]] = b + if b.get("kind") not in KINDS: + errors.append(f"「{name}」 모르는 상자 종류 「{b.get('kind')}」") + elif b["kind"] == "function" and not ((b.get("func") or {}).get("output") or {}).get( + "name" + ): + errors.append(f"「{name}」 함수 상자에 결과 알약이 없음") + taken = set() + for ln in flow["links"]: + src, to = (ln or {}).get("from") or {}, (ln or {}).get("to") or {} + a, b = boxes.get(src.get("box")), boxes.get(to.get("box")) + if ( + a is None + or b is None + or src.get("name") not in _outs(a) + or to.get("name") not in _ins(b) + ): + errors.append(f"선 「{(ln or {}).get('id')}」 이 없는 알약을 이음") + continue + if (to["box"], to["name"]) in taken: + errors.append(f"「{b.get('name')} · {to['name']}」 에 선이 둘 이상") + taken.add((to["box"], to["name"])) + for p in dc._sources(ln.get("rule")): + if p.get("name") not in _outs(boxes.get(p.get("box")) or {}): + errors.append(f"선 「{ln.get('id')}」 규칙의 조건 알약이 없음") + if not errors: + errors += [ + f"「{b.get('name')}」 상자가 서로를 기다림(돌고 도는 선)" for b in dc._waves(flow)[1] + ] + if errors: + raise store.StoreError(422, {"errors": errors}) + return [ + f"「{b.get('name')} · {port}」 받는 자리에 들어오는 선이 없음" + for b in boxes.values() + if b.get("kind") != "input" + for port in sorted(_ins(b) - {n for bx, n in taken if bx == b["id"]}, key=str) + ] + + +# ── 다른 로직 · 계산 ─────────────────────────────────────────────────── +def _old(key: str, designs: dict) -> dict: + """옛 로직 값 — 옛 셈 · 내보내는 알약 = 노무비 · 재료비 · 경비 · 계(돈 아닌 로직은 결과).""" + got = store.calc(key, {k: v for k, v in designs.items() if v is not None}) + if not got.get("ok"): + return {"outputs": {}, "errors": [got.get("reason") or "옛 셈 오류"]} + if "result" in got: + return {"outputs": {"결과": got["result"]}, "errors": []} + return {"outputs": dict(got["sums"]), "errors": []} + + +def load(key: str) -> dict: + """다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 옛 로직이면 옛 셈 함수(`calc`).""" + row = store.logic(key)["logic"] + if isinstance(row.get(FLOW), dict): + return {**row[FLOW], "key": key} + return {"key": key, "calc": lambda designs: _old(key, designs)} + + +def old_designs(key: str) -> list[dict]: + """옛 로직의 받는 알약 = 로직 입력 — Model `CfDesign` 꼴.""" + out = [] + for one in store.logic(key)["logic"].get("입력") or []: + choices = one.get("고르기") + variable = {"name": one.get("이름"), "type": "choice" if choices else "number", + "unit": one.get("단위") or "-", "example": None} # fmt: skip + if choices: + variable["choices"] = choices + out.append({"box": "", "port": one.get("이름"), "kind": "변수", + "unit": one.get("단위") or "-", "variable": variable}) # fmt: skip + return out + + +def _value(v: Any) -> Any: + """화면이 보낸 값 — 수 글은 수로(「3」 → 3) · 그 밖은 그대로.""" + if isinstance(v, str) and v.strip(): + try: + return Decimal(v.strip()) + except InvalidOperation: + return v + return store._dec(v) + + +def run(flow: dict, key: str, inputs: dict) -> dict: + """흐름 셈 — 입력 = 「설계 때 정할 것」 이름 → 값.""" + given = {k: _value(v) for k, v in (inputs or {}).items()} + flow = {**flow, "key": key} + return dc.run(flow, dc._design_values(flow, given), load, flow.get("userElements") or [], [key]) + + +def calc(key: str, inputs: dict, row: dict | None = None) -> dict | None: + """계산 갈래 — 줄에 흐름이 없으면 None(옛 셈 그대로) · 있으면 흐름 셈을 `/calc` 꼴로.""" + if row is not None: + flow, key = row.get(FLOW), str(row.get("키") or key) + else: + try: + flow = store.logic(key)["logic"].get(FLOW) + except store.StoreError: + return None + if not isinstance(flow, dict): + return None + got = run(flow, key, inputs) + names = {b["id"]: b for b in flow.get("boxes") or []} + outputs = [ + {"box": i, "name": names[i].get("name"), "cost": (names[i].get("output") or {}).get("cost"), + "unit": (names[i].get("output") or {}).get("unit") or names[i].get("unit"), "value": v} + for i, v in got["outputs"].items() if i in names + ] # fmt: skip + cost = got["cost"] + out = {"ok": not got["errors"], "flow": True, "lines": [], "middle": {}, + "sums": {k: cost[k] for k in (*dc.COSTS, "계")}, "values": cost["값들"], + "outputs": outputs, "errors": got["errors"], "breaks": got["breaks"]} # fmt: skip + if got["errors"]: + out["reason"] = " · ".join(got["errors"]) + return out + + +# ── 흐름 받기 ────────────────────────────────────────────────────────── +def _row(file: str, key: str) -> dict: + if not file: + return store.logic(key)["logic"] + data = store.read(file)[0] + hit = next((r for r in data.get("줄") or [] if str(r.get("키")) == key), None) + if hit is None: + raise store.StoreError(404, f"{file} · 없는 키 「{key}」") + return hit + + +def get_flow(file: str, key: str) -> dict: + """{flow, converted, notes} — 흐름이 있으면 그대로 · 없으면 변환(파일은 안 바뀜).""" + row = _row(file, key) + if isinstance(row.get(FLOW), dict): + return {"flow": {**row[FLOW], "key": key}, "converted": False, "notes": []} + try: + convert = importlib.import_module(CONVERT).convert + except ModuleNotFoundError as e: + if e.name != CONVERT: + raise + empty = {"key": key, "name": row.get("이름") or "", "resultUnit": row.get("결과단위") or "", + "boxes": [], "links": [], "userElements": []} # fmt: skip + return {"flow": empty, "converted": True, "notes": [NOT_READY]} + got = convert(row, {"key": key, "file": file, "master": store.loaded()[1]}) + return {"flow": {**got["flow"], "key": key}, "converted": True, "notes": got.get("notes") or []} + + +# ── 저장 ────────────────────────────────────────────────────────────── +def _redo(row: dict, key: str) -> list[str]: + """흐름 검사 → 서버 셈으로 다시 셈 → 받는 칸 값만 되돌려 넣음 · 돌려받는 것 = 경고.""" + warns = check(row[FLOW]) + got = run(row[FLOW], key, {}) + filled = got["filled"] + filled.pop("key", None) # 키는 줄의 「키」 하나 — 흐름 안에 두지 않음 + row[FLOW] = filled + return warns + got["errors"] + + +def _bare(row: dict) -> dict: + """새 흐름 로직 — 옛 식 칸은 빈 것으로(파일 틀 검사) · 출처 없으면 「자체」.""" + old = {"입력": [], "중간": [], "호표": [], "끝수": None} + return {**old, **row, "출처": row.get("출처") or "자체"} + + +def save(batch: list[dict]) -> dict: + """`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로.""" + warns, fresh = [], False + for part in batch: + for ch in part.get("changes") or []: + row = ch.get("row") + if not isinstance(row, dict) or FLOW not in row: + continue + new = ch.get("op") == "add" or (ch.get("op") == "edit" and not ch.get("key")) + if new: + if part.get("file") != make.OWN: + raise store.StoreError(400, f"새 흐름 로직은 「{make.OWN}」 에만 더함") + row = make._clean(_bare(row), str(row.get("소유") or make.OWNERS[0])) + ch.update(op="add", key=None) + ch["row"] = row + name = row.get("이름") or ch.get("key") or "새 로직" + warns += [f"「{name}」 {w}" for w in _redo(row, str(ch.get("key") or ""))] + if part.get("file") == make.OWN and not make._own_path().is_file(): + make._make_file() + fresh = True + part["version"] = part.get("version") or store.read(make.OWN)[1] + before = {p["file"]: _keys(p["file"]) for p in batch} + try: + files = store.save(batch) + except store.StoreError: + if fresh: + make._own_path().unlink(missing_ok=True) + raise + added = [{"file": f, "key": k} for f, old in before.items() for k in _keys(f) if k not in old] + return {"files": files, "added": added, "warnings": warns} + + +def _keys(file: str) -> list[str]: + try: + data = store.read(file)[0] + except store.StoreError: + return [] + return [str(r.get("키")) for r in data.get(store.items_key(data)) or []] diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index 331a2a151..e3d8cf96b 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -23,8 +23,10 @@ from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal from typing import Any, Callable from common_util import common_util_spreadsheet as spreadsheet +from M01_MasterData import ( + M01_MasterData_Router as m01, +) # _num — M01 Router 가 이 파일을 부름(서로 부름) from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData.M01_MasterData_Router import _num from M02_MasterTemplete.DataFlow import M02_DF_Values as dv _CELL = re.compile(r"^([A-Z]+)(\d+)$") @@ -188,6 +190,12 @@ def _logic_ports(lg: dict, box: dict, given: dict, ports: dict, errors: list, ct if link.get("to", {}).get("box") == box["id"]: src = link.get("from") or {} designs[link["to"].get("name")] = ports.get(src.get("box"), {}).get(src.get("name")) + if callable( + other.get("calc") + ): # 옛 마스터 로직(흐름 없음) — 옛 셈 · 노무비 · 재료비 · 경비 · 계 + result = other["calc"](designs) + errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]] + return result["outputs"] stack = [*ctx["stack"], key] result = run(other, _design_values(other, designs), ctx["load"], ctx["local"], stack) errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]] @@ -559,7 +567,9 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N outputs[b["id"]] = arrive(b).get(OUTPUT_PORT) if funcs: # 이 물결의 함수 시트를 한 번에(시트마다 통합문서 하나 — Eval 과 같음) books = [ - _num({"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]}) + m01._num( + {"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]} + ) for f in funcs ] try: diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Router.py b/M02_MasterTemplete/DataFlow/M02_DF_Router.py index c7375090c..3892588d9 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Router.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Router.py @@ -36,6 +36,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Flow as sflow from M01_MasterData.M01_MasterData_Router import _num from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc from M02_MasterTemplete.DataFlow import M02_DF_Values as dv @@ -184,7 +185,10 @@ def user_edit(op: str, element: dict | None, uid: str | None) -> dict: # ── 저장 · 계산(`M02_DF_Compute`) ───────────────────────────────────── def _load(key: str) -> dict: - """다른 로직 입력이 부르는 로직 — 내 것(`_data`) · 견본(`_sample`).""" + """다른 로직 입력이 부르는 로직 — 내 것(`_data`) · 견본(`_sample`) · 마스터 키(GX · GF …)는 + M01 계산 길(흐름이면 흐름 · 옛 로직이면 옛 셈 · 정함 11).""" + if not _KEY.match(key or "") and _sample(key) is None: + return sflow.load(key) logic, _ = _read(key) return {**logic, "key": key} @@ -216,6 +220,9 @@ def save(logic: dict) -> dict: def logic_value(key: str, designs: dict) -> dict: """다른 로직 값 — 저장 로직 · 견본을 받은 설계 값(없으면 예시값)으로 셈 · 출력 이름 → 값.""" other = _load(key) + if callable(other.get("calc")): # 옛 마스터 로직 — 노무비 · 재료비 · 경비 · 계 + got = other["calc"](designs) + return {"key": key, "outputs": got["outputs"], "cost": {}, "errors": got["errors"]} got = dc.run(other, dc._design_values(other, designs), _load, _local(other), [key]) names = {b["id"]: b.get("name") for b in other.get("boxes") or []} outputs = {names[i]: v for i, v in got["outputs"].items() if i in names} @@ -224,7 +231,10 @@ def logic_value(key: str, designs: dict) -> dict: def design_inputs(key: str) -> dict: """설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(선이 없을 때) · CfDesign 꼴.""" - return {"key": key, "designs": dc.design_inputs(_load(key))} + other = _load(key) + if callable(other.get("calc")): + return {"key": key, "designs": sflow.old_designs(key)} + return {"key": key, "designs": dc.design_inputs(other)} # ── 길 ──────────────────────────────────────────────────────────────── diff --git a/resources/tester/test_m01_logic_flow_save.py b/resources/tester/test_m01_logic_flow_save.py new file mode 100644 index 000000000..312664317 --- /dev/null +++ b/resources/tester/test_m01_logic_flow_save.py @@ -0,0 +1,240 @@ +# ruff: noqa: E501 +"""흐름도 적용(PLAN 36-4 서버 · 정함 1 · 2 · 4 · 10 · 11 · 12) — 로직 줄 「흐름」 칸. + +못박는 것(마스터 사본 폴더에서만 · 정본은 안 바뀜) + - `/save` 줄에 실은 흐름 = 검사 → 서버 다시 셈(받는 칸에 값) → 파일에 씀 · 다시 읽으면 그대로 + - 흐름 꼴이 틀리면(없는 알약 · 돌고 도는 선) 422 · 빈 받는 자리는 경고 + - `/calc` 갈래 — 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · 노무비 · 재료비 · 경비 · 계) · 없으면 옛 셈 + - 새 줄(키 없음) + 흐름 → 자체 파일 GX 다음 키 · 구분 자체 · 본떠 만들기는 흐름도 함께 + - `GET /logic/flow` — 흐름 있으면 그대로 · 없으면 변환(준비 전이면 빈 흐름 + 「변환 준비 중」) + - DataFlow 다른 로직 값 · 설계 입력이 마스터 키도 받음(흐름 · 옛 로직) · 서로 부르면 막음 + - 시스템 관리자가 아니면 403 +""" + +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import shutil +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import require_system_admin, verify_session +from M01_MasterData import M01_MasterData_Router as m01_router +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Flow as sflow +from M02_MasterTemplete.DataFlow import M02_DF_Router as df + +REAL = store.FOLDER +KEY = "GF000219" # 13-4-1 메쌓기 — 흐름을 얹어 볼 정본 로직 +OLD = "GF000160" # 12-4 합판거푸집 — 흐름 없는 옛 로직 +OWN = "로직_자체.json" + + +def _flow(name: str = "넓이 품") -> dict: + """길이 × 폭 × 1000 → 노무비 — 입력 상자(변수 둘) · 함수 상자 · 출력 상자.""" + sheet = {"id": "s-fn", "이름": "넓이", "칸": {"A2": {"값": "길이"}, "A3": {"값": "폭"}, + "A4": {"값": "금액"}, "B4": {"식": "B2*B3*1000"}}} # fmt: skip + return { + "key": None, "name": name, "resultUnit": "원/㎡", "userElements": [], + "boxes": [ + {"id": "in", "kind": "input", "name": "치수", "color": "", "unit": "-", "x": 0, "y": 0, + "input": {"mode": "variable", "elements": [], "variables": [ + {"name": "길이", "type": "number", "unit": "m", "example": 2}, + {"name": "폭", "type": "number", "unit": "m", "example": 3}]}}, + {"id": "fn", "kind": "function", "name": "넓이 금액", "color": "", "unit": "원", "x": 0, "y": 0, + "func": {"sheet": sheet, "inputs": [{"port": "길이", "cell": "B2"}, {"port": "폭", "cell": "B3"}], + "variables": [], "output": {"cell": "B4", "name": "금액", "unit": "원"}}}, + {"id": "out", "kind": "output", "name": "노무비", "color": "", "unit": "원/㎡", "x": 0, "y": 0, + "output": {"cost": "노무비", "unit": "원/㎡"}}, + ], + "links": [ + {"id": "l1", "from": {"box": "in", "name": "길이"}, "to": {"box": "fn", "name": "길이"}}, + {"id": "l2", "from": {"box": "in", "name": "폭"}, "to": {"box": "fn", "name": "폭"}}, + {"id": "l3", "from": {"box": "fn", "name": "금액"}, "to": {"box": "out", "name": "값"}}, + ], + } # fmt: skip + + +def _hashes(folder: Path) -> dict[str, str]: + return {p.name: hashlib.sha1(p.read_bytes()).hexdigest() for p in folder.glob("*.json")} + + +@pytest.fixture +def folder(tmp_path: Path, monkeypatch) -> Path: + master = tmp_path / "master" + master.mkdir() + for path in REAL.glob("*.json"): + shutil.copy(path, master / path.name) + monkeypatch.setattr(store, "FOLDER", master) + monkeypatch.setattr(df, "DATA", tmp_path / "_data") + return master + + +@pytest.fixture +def client(folder: Path) -> TestClient: + app = FastAPI() + app.include_router(m01_router.router) + app.include_router(df.router) + return TestClient(app) + + +def _get(client: TestClient, url: str, want: int = 200, **params) -> dict: + res = client.get(url, params=params) + assert res.status_code == want, res.text + return res.json() + + +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 _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dict: + got = _get(client, "/api/m01/logic", key=key) + row = {**got["logic"], "흐름": flow} + part = {"file": got["file"], "version": got["version"], + "changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip + return _post(client, "/api/m01/save", {"files": [part]}, want) + + +def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None: + before = _hashes(REAL) + done = _save_flow(client, KEY, _flow()) + assert done["warnings"] == [] and done["added"] == [] + saved = _get(client, "/api/m01/logic", key=KEY)["logic"]["흐름"] + cells = saved["boxes"][1]["func"]["sheet"]["칸"] + assert ( + Decimal(str(cells["B2"]["값"])) == 2 and Decimal(str(cells["B3"]["값"])) == 3 + ) # 서버가 넣은 값 + assert "key" not in saved + got = _get(client, "/api/m01/logic/flow", key=KEY) + assert got["converted"] is False and got["notes"] == [] and got["flow"]["key"] == KEY + assert [b["id"] for b in got["flow"]["boxes"]] == ["in", "fn", "out"] + assert _hashes(REAL) == before # 사본 밖(정본)은 안 바뀜 + + +def test_흐름_꼴_검사_422_와_빈_자리_경고(client: TestClient) -> None: + bad = _flow() + bad["links"][0]["from"]["name"] = "없는알약" + assert "없는 알약" in str(_save_flow(client, KEY, bad, 422)) + loop = _flow() + loop["boxes"].append({**copy.deepcopy(loop["boxes"][1]), "id": "fn2", "name": "되돌이"}) + loop["links"] += [ + {"id": "l4", "from": {"box": "fn", "name": "금액"}, "to": {"box": "fn2", "name": "길이"}}, + {"id": "l5", "from": {"box": "fn2", "name": "금액"}, "to": {"box": "fn", "name": "폭"}}, + ] + loop["links"] = [x for x in loop["links"] if x["id"] != "l2"] + assert "돌고 도는" in str(_save_flow(client, KEY, loop, 422)) + empty = _flow() + empty["links"] = [x for x in empty["links"] if x["id"] != "l2"] + warns = _save_flow(client, KEY, empty)["warnings"] + assert any("넓이 금액 · 폭" in w for w in warns), warns + + +def test_계산_갈래_흐름이면_흐름_셈_아니면_옛_셈(client: TestClient) -> None: + _save_flow(client, KEY, _flow()) + got = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {"길이": 4, "폭": "5"}}) + assert got["flow"] is True and got["ok"] is True + assert {k: Decimal(str(v)) for k, v in got["sums"].items()} == { + "노무비": 20000, "재료비": 0, "경비": 0, "계": 20000} # fmt: skip + assert ( + got["outputs"][0]["name"] == "노무비" and Decimal(str(got["outputs"][0]["value"])) == 20000 + ) + plain = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}}) # 예시값 2 × 3 + assert Decimal(str(plain["sums"]["계"])) == 6000 + row = {**_get(client, "/api/m01/logic", key=KEY)["logic"], "흐름": _flow()} # 저장 전 줄 + row["흐름"]["boxes"][1]["func"]["sheet"]["칸"]["B4"] = {"식": "B2*B3*10"} + draft = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}, "row": row}) + assert Decimal(str(draft["sums"]["계"])) == 60 + old = _post(client, "/api/m01/calc", {"key": OLD, "inputs": {}}) + assert "flow" not in old # 흐름 없는 로직은 옛 셈 그대로 + + +def test_신규_흐름_로직은_자체_GX_복사는_흐름도(client: TestClient, folder: Path) -> None: + version = store.read(OWN)[1] if (folder / OWN).is_file() else "" + row = {"키": "", "이름": "시험 흐름 로직", "결과단위": "원/㎡", "출처": "", "구분": "산림품셈", + "흐름": _flow("시험 흐름 로직")} # fmt: skip + part = {"file": OWN, "version": version, "changes": [{"op": "add", "row": row}]} + done = _post(client, "/api/m01/save", {"files": [part]}) + assert len(done["added"]) == 1 and done["added"][0]["file"] == OWN + key = done["added"][0]["key"] + assert key.startswith("GX") + saved = _get(client, "/api/m01/logic", key=key)["logic"] + assert saved["구분"] == "자체" and saved["흐름"]["name"] == "시험 흐름 로직" + assert ( + Decimal(str(_post(client, "/api/m01/calc", {"key": key, "inputs": {}})["sums"]["계"])) + == 6000 + ) + made = _post(client, "/api/m01/logic/copy", {"key": key, "이름": "복사한 흐름"}) + assert made["key"].startswith("GX") and made["key"] != key + copied = _get(client, "/api/m01/logic/flow", key=made["key"]) + assert copied["converted"] is False and len(copied["flow"]["boxes"]) == 3 + bad = {"file": OWN, "version": store.read(OWN)[1], "changes": [{"op": "add", "row": row}]} + bad["changes"][0]["row"] = {**row, "흐름": {"boxes": "아님"}} + _post(client, "/api/m01/save", {"files": [bad]}, 422) + + +def test_흐름_없는_로직_받기는_변환(client: TestClient) -> None: + got = _get(client, "/api/m01/logic/flow", key=OLD) + assert got["converted"] is True and got["flow"]["key"] == OLD + if importlib.util.find_spec(sflow.CONVERT) is None: # sub7 변환이 오기 전 + assert got["notes"] == [sflow.NOT_READY] and got["flow"]["boxes"] == [] + _get(client, "/api/m01/logic/flow", 404, key="GX999999") + + +def test_다른_로직_값_마스터_키_흐름_옛_순환(client: TestClient) -> None: + _save_flow(client, KEY, _flow()) + got = _post(client, "/api/m02/df/logic-value", {"key": KEY, "designs": {"길이": 10}}) + assert Decimal(str(got["outputs"]["노무비"])) == 30000 and got["errors"] == [] + designs = _get(client, "/api/m02/df/design-inputs", key=KEY)["designs"] + assert [d["port"] for d in designs] == ["길이", "폭"] + given = {k: str(v) for k, v in store.cm.시험입력(store.logic(OLD)["logic"]).items()} + old = _post(client, "/api/m02/df/logic-value", {"key": OLD, "designs": given}) + want = _post(client, "/api/m01/calc", {"key": OLD, "inputs": given}) + assert old["outputs"] == want["sums"] and set(old["outputs"]) == { + "노무비", + "재료비", + "경비", + "계", + } + names = [d["port"] for d in _get(client, "/api/m02/df/design-inputs", key=OLD)["designs"]] + assert names == [x["이름"] for x in store.logic(OLD)["logic"].get("입력") or []] + # 흐름 안 다른 로직 입력이 옛 로직(OLD) 계 를 받아 노무비로 · 자기 자신을 부르면 막힘 + flow = _flow() + pills = [{"name": k, "unit": "-"} for k in given] + ref = {"key": OLD, "outputs": [{"name": "계", "unit": "원"}], "designs": pills} + flow["boxes"].append({"id": "lg", "kind": "input", "name": "거푸집", "color": "", "unit": "-", + "x": 0, "y": 0, "input": {"mode": "logic", "elements": [], "variables": [], + "logic": ref}}) # fmt: skip + flow["boxes"].append({**copy.deepcopy(flow["boxes"][2]), "id": "out2", "name": "경비"}) + flow["boxes"][-1]["output"]["cost"] = "경비" + flow["links"].append( + {"id": "l9", "from": {"box": "lg", "name": "계"}, "to": {"box": "out2", "name": "값"}} + ) + _save_flow(client, KEY, flow) + mixed = _post(client, "/api/m01/calc", {"key": KEY, "inputs": given}) + assert Decimal(str(mixed["sums"]["경비"])) == Decimal(str(want["sums"]["계"])) + ref["key"] = KEY + self_call = _save_flow(client, KEY, flow)["warnings"] + assert any("서로를 부름" in w for w in self_call), self_call + + +def test_시스템_관리자가_아니면_403(folder: Path) -> None: + app = FastAPI() + app.include_router( + m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] + ) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} + client = TestClient(app) + before = _hashes(folder) + assert client.get("/api/m01/logic/flow", params={"key": KEY}).status_code == 403 + assert client.post("/api/m01/save", json={"files": []}).status_code == 403 + assert client.post("/api/m01/calc", json={"key": KEY, "inputs": {}}).status_code == 403 + assert _hashes(folder) == before