Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3

This commit is contained in:
2026-10-01 23:42:51 +09:00
22 changed files with 2129 additions and 75 deletions
+7
View File
@@ -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;
@@ -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);
}
@@ -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<CondBranch>): 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();
},
};
}
@@ -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<CondOp, string> = {
"=": "=",
"≠": "<>",
"≥": ">=",
"≤": "<=",
">": ">",
"<": "<",
};
export const FROM_XL: Record<string, CondOp> = {
"=": "=",
"<>": "≠",
">=": "≥",
"<=": "≤",
">": ">",
"<": "<",
};
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;
}
+13 -1
View File
@@ -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")
+269
View File
@@ -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 []]
@@ -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<FlowGroupKey, TextKey> = {
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<FormulaFn | null> => {
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];
}
}),
);
});
}
@@ -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 })],
}),
);
}
+12 -2
View File
@@ -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:
+12 -3
View File
@@ -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<HTMLElement>(`.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
? []
: [
@@ -124,6 +124,8 @@ export interface DiagramOpts {
fitKey?: number;
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
fitMin?: number;
/** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */
fitAllMin?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -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 = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
function outside(text: string, fn: (part: string) => 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<string, string> {
const f = box.func;
const out: Record<string, string> = {};
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, string>): 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, CellScalar>): 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<string, CellScalar>): 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<string, string>,
values: Record<string, CellScalar>,
): 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<string, CellScalar>): 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<string>();
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<string, unknown> | undefined,
): Record<string, CellScalar> {
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<string, CellScalar> = {};
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") })]),
);
}
@@ -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<string, string>;
}
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! })),
];
},
};
}
@@ -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);
}
@@ -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<void> {
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);
+136 -22
View File
@@ -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<CfRule, { kind: "lookup" }>): 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<CfRule, { kind: "gate" }>): HTMLElement[] {
const put = (next: Partial<typeof rule>): 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<CfGateCond>): 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<CfRule, { kind: "scale" }>): HTMLElement[] {
@@ -323,7 +428,7 @@ function roundEditor(
const DEFAULT: Record<Exclude<Kind, "none">, (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)})` }),
],
}),
+6 -3
View File
@@ -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));
@@ -55,6 +55,8 @@ export interface PaperOpts {
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
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 });
+12 -2
View File
@@ -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)}
# ── 길 ────────────────────────────────────────────────────────────────
@@ -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
+16 -1
View File
@@ -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
@@ -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