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

This commit is contained in:
2026-10-01 23:58:11 +09:00
63 changed files with 5521 additions and 1270 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;
}
+16 -3
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"])
@@ -191,8 +192,9 @@ def get_logic(key: str) -> dict:
@router.get("/logic/auto")
def get_logic_auto(key: str) -> dict:
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님)."""
return _call(store.auto, key)
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값."""
got = _call(flow.auto, key)
return got if got is not None else _call(store.auto, key)
@router.get("/elements")
@@ -221,6 +223,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 +240,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")
+328
View File
@@ -0,0 +1,328 @@
"""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 contextvars import ContextVar
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
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
FLOW = "흐름"
KINDS = ("input", "function", "output")
CONVERT = "M02_MasterTemplete.DataFlow.M02_DF_Convert"
NOT_READY = "변환 준비 중"
_ACTIVE: ContextVar[tuple] = ContextVar("m01_flow_active", default=()) # 셈 중인 흐름 키
# ── 검사(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:
"""옛 로직 값 — 옛 셈 · 내보내는 알약 = 노무비 · 재료비 · 경비 · 계(돈 아닌 로직은 결과)."""
given = {k: v for k, v in designs.items() if v is not None}
whole, mf = dv.SOURCE.get(), store.cm.mf
if whole is not None: # 단가 자리(프로젝트 사본)의 옛 로직
try:
given = mf.fill_empty(whole.logic(key), {k: _value(v) for k, v in given.items()})
got = mf.run(whole, key, given)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"outputs": {}, "errors": [str(e) or type(e).__name__]}
if "결과" in got:
return {"outputs": {"결과": got["결과"]}, "errors": []}
return {"outputs": {k: got[k] for k in mf.BUNDLE}, "errors": []}
got = store.calc(key, given)
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`)."""
whole = dv.SOURCE.get()
row = whole.logic(key) if whole is not None else 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:
"""흐름 셈 — 입력 = 「설계 때 정할 것」 이름 → 값."""
active = _ACTIVE.get()
if key and key in active: # 옛 셈을 건너 다시 돌아온 흐름(흐름 → 옛 로직 → 흐름)
raise store.cm.mf.FormulaError(f"로직이 서로를 부름 — {' → '.join([*active, key])}")
token = _ACTIVE.set((*active, key))
try:
given = {k: _value(v) for k, v in (inputs or {}).items()}
flow = {**flow, "key": key}
values = dc._design_values(flow, given)
return dc.run(flow, values, load, flow.get("userElements") or [], [key])
finally:
_ACTIVE.reset(token)
def _engine(master, ref: str, row: dict, given: dict) -> dict:
"""옛 엔진 `mf.run` 갈래(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…)) — `run` 과 같은 꼴.
단가 · 값 · 표 = 부른 쪽이 넘긴 master(프로젝트 사본이면 그 단가 · `M02_DF_Values.SOURCE`).
"""
token = dv.SOURCE.set(master)
try:
got = run(row[FLOW], ref, given)
finally:
dv.SOURCE.reset(token)
if got["errors"]:
raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(got['errors'])}")
cost = got["cost"]
kinds = {(b.get("output") or {}).get("cost") for b in row[FLOW].get("boxes") or []
if b.get("kind") == "output"} # fmt: skip
if kinds and kinds <= {"값"} and len(cost["값들"]) == 1: # 돈 아닌 로직 = 결과 하나
return {"결과": next(iter(cost["값들"].values())), "중간": {}}
return {"줄": [], **{k: cost[k] for k in dc.COSTS}, "계": cost["계"], "중간": {}}
store.cm.mf.FLOW_RUN = _engine # 옛 엔진을 부르는 곳이 모두 흐름 로직을 흐름 셈으로(한 곳 갈래)
def auto(key: str) -> dict | None:
"""견본 입력 — 흐름 로직이면 「설계 때 정할 것」 예시값 · 아니면 None(옛 자동값)."""
flow = store.logic(key)["logic"].get(FLOW)
if not isinstance(flow, dict):
return None
return {
"값": {d["port"]: (d.get("variable") or {}).get("example") for d in dc.design_inputs(flow)}
}
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
try:
got = run(flow, key, inputs)
except store.cm.mf.FormulaError as e:
return {"ok": False, "flow": True, "reason": str(e)}
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 []]
+117 -103
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import { t as L } from "@ui/ui_template_locale";
import {
createButton,
el,
@@ -19,6 +19,7 @@ import {
import {
ApiError,
fetchLogic,
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
saveFiles,
@@ -37,12 +38,17 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry";
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
import { createDataFlowButton } from "../M02_MasterTemplete/DataFlow/M02_DF_Entry";
import {
buildSplit,
emptyFlow,
fetchFlow,
mountLogicFlow,
tf,
type LogicFlow,
} from "./M01_MasterData_UI_LogicLab_Flow";
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -67,54 +73,17 @@ interface Opened extends Draft {
lastText?: TextAnswer;
ownHost: HTMLElement;
values: Record<string, string>;
/** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */
flowHost: HTMLElement;
}
/** 줄의 흐름(있으면 흐름도가 정본) */
const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null;
const OWN_FILE = "로직_자체.json";
const cacheKey = "m01_logic_drafts_lab";
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */
function trialMenu(items: HTMLElement[]): HTMLElement {
const list = el("div", { attrs: { role: "menu" }, children: items });
Object.assign(list.style, {
position: "absolute",
top: "calc(100% + 4px)",
left: "0",
zIndex: "20",
display: "none",
flexDirection: "column",
gap: "var(--spacing-4)",
padding: "var(--spacing-8)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
background: "var(--color-surface-raised)",
boxShadow: "var(--shadow-lg)",
whiteSpace: "nowrap",
});
const shut = (): void => {
list.style.display = "none";
button.setAttribute("aria-expanded", "false");
document.removeEventListener("click", outside, true);
};
const outside = (ev: Event): void => {
if (!wrap.contains(ev.target as Node)) shut();
};
const button = createButton({
label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0],
variant: "ghost",
onClick: () => {
if (list.style.display !== "none") return shut();
list.style.display = "flex";
button.setAttribute("aria-expanded", "true");
document.addEventListener("click", outside, true);
},
});
button.setAttribute("aria-haspopup", "menu");
list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶
const wrap = el("div", { children: [button, list] });
Object.assign(wrap.style, { position: "relative", display: "inline-flex" });
wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut());
return wrap;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
@@ -137,13 +106,18 @@ export async function mountM01LogicLab(
): Promise<LogicHandle> {
let drafts = loadDrafts();
let opened: Opened | null = null;
let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음
let calcInputs = "";
/** 오른쪽 흐름도 패널 — 로직을 열 때마다 새로 */
let flowNow: LogicFlow | null = null;
const admin = await fetchSessionUser()
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
const split = buildSplit(editor);
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("div", { className: "m01lab__calc" });
const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } });
const filterHost = el("div");
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
@@ -233,12 +207,11 @@ export async function mountM01LogicLab(
const drawEditor = (): void => {
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
const listing = !opened && !creating;
const listing = !opened;
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
editor.hidden = !opened;
split.root.hidden = !opened;
back.hidden = listing;
newHost.hidden = !creating;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
@@ -259,15 +232,54 @@ export async function mountM01LogicLab(
ownHost: current.ownHost,
onChange: touch,
calcHost: calc,
flowHost: flowOf(current.row) ? current.flowHost : undefined,
});
flowNow?.redrawLeft();
};
/** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */
const startFlow = async (o: Opened): Promise<void> => {
const row = o.row;
if (!row) return;
split.right.replaceChildren(el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }));
const own = flowOf(row);
const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file);
const flow = own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위));
if (opened !== o) return;
try {
const made = await mountLogicFlow({
split,
flow: clone(flow),
title: row.이름 || row.키,
readOnly: !admin,
notes: got?.notes ?? [],
converted: !!got?.converted,
leftHost: () => (flowOf(o.row) ? o.flowHost : null),
onChange: (lg) => {
if (!o.row || opened !== o) return;
const was = flowOf(o.row);
o.row.흐름 = lg;
touch();
if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로
},
});
if (opened !== o) return made.destroy();
flowNow = made;
} catch (e) {
split.right.replaceChildren(
el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }),
);
}
};
const show = (next: Opened | null): void => {
flowNow?.destroy();
flowNow = null;
opened = next;
creating = false;
errors.hidden = true;
drawEditor();
drawCalc();
if (next?.row) void startFlow(next);
};
const open = async (id: string, sub: string, key: string): Promise<void> => {
@@ -283,6 +295,7 @@ export async function mountM01LogicLab(
text: null,
ownHost: ownHost(),
values: {},
flowHost: el("div"),
});
return;
}
@@ -302,6 +315,7 @@ export async function mountM01LogicLab(
text: null,
ownHost: ownHost(),
values: {},
flowHost: el("div"),
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
@@ -326,7 +340,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened || creating) show(null);
if (opened) show(null);
},
}),
);
@@ -380,12 +394,18 @@ export async function mountM01LogicLab(
}
showLoadingOverlay();
try {
await saveFiles([...byFile.values()]);
// 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶
const saved = (await saveFiles([...byFile.values()])) as {
added?: { file: string; key: string }[];
};
const was = opened;
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined;
const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined;
if (made) await open(logicId(made.구분, made.키), made.구분, made.키);
else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
else show(null);
} catch (error) {
failed(error);
@@ -410,27 +430,26 @@ export async function mountM01LogicLab(
}
};
/** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */
const startNew = (): void => {
opened = null;
creating = true;
newHost.replaceChildren(
buildNewLogic({
onSaved: (key) => {
void (async () => {
try {
await reload();
await openKeyOn(key);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
})();
},
onClose: () => show(null),
}),
);
drawEditor();
drawCalc();
const onNew = async (): Promise<void> => {
const units = [...new Set(allLogics.map((x) => x.결과단위).filter(Boolean))];
const info = await askBasicInfo(units.length ? units : ["원"]);
if (!info) return;
try {
const own = (await fetchLogicFiles()).find((f) => f.file === OWN_FILE);
const row: LogicRow = {
키: "",
...info,
입력: [],
중간: [],
흐름: await emptyFlow(info.이름, info.결과단위),
};
const id = logicId("자체", `새${Date.now()}`);
drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row };
persist();
await open(id, "자체", "");
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
@@ -439,36 +458,31 @@ export async function mountM01LogicLab(
variant: "ghost",
onClick: () => void onDiscard(),
});
const copyButton = createButton({
label: tl("Copy_Open"),
variant: "ghost",
onClick: () =>
opened?.origKey
? void openCopyScreen({
key: opened.origKey,
afterSave: async (newKey) => {
await reload();
await open(logicId("자체", newKey), "자체", newKey);
},
})
: showToast(tl("Copy_NeedOpen"), "info"),
});
const bar = el("div", {
className: "m01-logic__bar",
children: [
back,
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tn("Open"), variant: "filled", onClick: startNew }),
trialMenu([
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
createCostFlowButton(),
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
createDataFlowButton(),
]),
createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }),
copyButton,
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
createButton({
label: tl("Copy_Open"),
variant: "ghost",
onClick: () =>
opened?.origKey
? void openCopyScreen({
key: opened.origKey,
afterSave: async (newKey) => {
await reload();
await open(logicId("자체", newKey), "자체", newKey);
},
})
: showToast(tl("Copy_NeedOpen"), "info"),
}),
],
});
host.replaceChildren(
@@ -477,7 +491,7 @@ export async function mountM01LogicLab(
children: [
el("main", {
className: "m01-logic__main",
children: [bar, errors, waiting, list.root, editor, newHost],
children: [bar, errors, waiting, list.root, split.root],
}),
],
}),
@@ -0,0 +1,105 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Basic.ts
* 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null
* 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
const TEXT = {
Title: ["신규 로직", "New logic"],
Name: ["이름", "Name"],
NamePh: ["예: 돌쌓기 메쌓기(인력)", "e.g. dry stone wall (labour)"],
Number: ["원문번호", "Source number"],
NumberPh: ["예: 13-4-1 · 없으면 비움", "e.g. 13-4-1 · may be empty"],
Unit: ["결과 단위", "Result unit"],
Source: ["출처", "Source"],
Ok: ["만들기", "Create"],
Cancel: ["취소", "Cancel"],
Close: ["닫기", "Close"],
NeedName: ["이름을 넣어 주세요", "Enter a name"],
} as const;
const tb = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
export interface BasicInfo {
이름: string;
원문번호: string;
결과단위: string;
출처: string;
}
/** 기본정보 묻기 — units = 결과 단위 후보(마스터 로직의 결과단위) */
export function askBasicInfo(units: string[]): Promise<BasicInfo | null> {
return new Promise((resolve) => {
let done = false;
const finish = (v: BasicInfo | null): void => {
if (done) return;
done = true;
resolve(v);
};
const name = createInputField({ label: tb("Name"), placeholder: tb("NamePh"), compact: true });
const number = createInputField({
label: tb("Number"),
placeholder: tb("NumberPh"),
compact: true,
});
const unit = createSelectField({
label: tb("Unit"),
options: units.map((u) => ({ value: u, text: u })),
value: units[0] ?? "원",
compact: true,
});
const source = createInputField({ label: tb("Source"), value: "자체", compact: true });
const why = el("p", { className: "m01-logic__muted", attrs: { role: "alert" } });
const modal = openModal({
title: tb("Title"),
closeLabel: tb("Close"),
beforeClose: () => {
finish(null);
return true;
},
mount: (body, close) => {
const ok = (): void => {
const 이름 = name.input.value.trim();
if (!이름) {
why.textContent = tb("NeedName");
return void name.input.focus();
}
finish({
이름,
원문번호: number.input.value.trim(),
결과단위: unit.select.value,
출처: source.input.value.trim() || "자체",
});
close();
};
name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok());
body.append(
name.root,
number.root,
unit.root,
source.root,
why,
el("div", {
className: "m01-logic__bar",
children: [
createButton({ label: tb("Ok"), size: "sm", onClick: ok }),
createButton({
label: tb("Cancel"),
variant: "ghost",
size: "sm",
onClick: () => {
finish(null);
modal.close();
},
}),
],
}),
);
},
});
});
}
@@ -72,6 +72,9 @@ export interface DetailContext {
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
calcHost: HTMLElement;
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
flowHost?: HTMLElement;
}
type Group = "labor" | "material" | "cost" | "other";
@@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
: null;
host.replaceChildren(
infoBox(ctx),
hoBox(ctx),
section(tl("Formula_Title"), [formula]),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
section(tl("Calc_Title"), [ctx.calcHost]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
@@ -0,0 +1,54 @@
/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */
.m01lab-flow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: 0;
}
.m01lab-flow[hidden] {
display: none;
}
.m01lab-flow__left,
.m01lab-flow__right {
min-width: 0;
}
.m01lab-flow__bar {
align-self: stretch;
width: 8px;
margin: 0 var(--spacing-4);
border-radius: var(--radius-sm);
background: var(--color-border);
cursor: col-resize;
touch-action: none;
}
.m01lab-flow__bar:hover,
.m01lab-flow__bar:focus-visible {
background: var(--color-primary);
}
.m01lab-flow__wait {
margin: 0;
padding: var(--spacing-16);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
text-align: center;
}
.m01lab-flow__bar[hidden] {
display: none;
}
.m01lab-flow__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01lab-flow__note[hidden] {
display: none;
}
@@ -0,0 +1,263 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Flow.ts
* 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 패널
* 너비 — 패널이 없으면 두 칸 비율(끌기 막대 · localStorage) · 패널이 있으면 패널이 알린 너비(px · 패널 가장자리 끌기)
* 잇기(mountLogicFlow) — 패널(M02_DF_Panel) · 왼쪽 보기(LogicLab_FlowView) · 입력 값 · 다른 로직 흐름 받기
* DataFlow 파일은 import() 로 늦게(엔진이 M01 첫 묶음에 안 들어가게). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 2 · 5 · 6 · 7
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import type { CfValues } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
import "./M01_MasterData_UI_LogicLab_Flow.css";
const TEXT = {
Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"],
New: ["신규 로직", "New logic"],
Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"],
Converted: [
"옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본",
"Converted from the old formula — saving makes the flow the master",
],
Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"],
} as const;
export const tf = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
const STORE = "m01.logic.flowRatio";
const MIN = 0.25;
const MAX = 0.75;
const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r));
function readRatio(): number {
try {
const r = Number(localStorage.getItem(STORE));
return r ? clamp(r) : 0.5;
} catch {
return 0.5;
}
}
export interface SplitHandle {
root: HTMLElement;
/** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */
right: HTMLElement;
/** 패널이 정한 너비(px) — 끌기 막대는 숨김(패널 가장자리로 조절) · null = 비율로 돌아감 */
setRight(px: number | null): void;
}
/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */
export function buildSplit(left: HTMLElement): SplitHandle {
const right = el("div", { className: "m01lab-flow__right" });
const bar = el("div", {
className: "m01lab-flow__bar",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": tf("Resize"),
title: tf("Resize"),
tabindex: "0",
},
});
const leftCol = el("div", { className: "m01lab-flow__left", children: [left] });
const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] });
let ratio = readRatio();
let panelPx: number | null = null;
const apply = (): void => {
root.style.gridTemplateColumns =
panelPx === null
? `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`
: `minmax(0, 1fr) ${panelPx}px`;
};
const keep = (): void => {
try {
localStorage.setItem(STORE, String(ratio));
} catch {
/* 기억 못 해도 지금 화면은 그대로 */
}
};
bar.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const box = root.getBoundingClientRect();
const move = (m: PointerEvent): void => {
ratio = clamp((m.clientX - box.left) / box.width);
apply();
};
const up = (): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
keep();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
// 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩
bar.addEventListener("keydown", (ev) => {
const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0;
if (!step) return;
ev.preventDefault();
ratio = clamp(ratio + step);
apply();
keep();
});
const setRight = (px: number | null): void => {
panelPx = px;
bar.hidden = px !== null;
apply();
};
apply();
return { root, right, setRight };
}
/* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */
export interface FlowAnswer {
flow: CfLogic;
converted: boolean;
notes: string[];
}
/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */
export async function fetchFlow(key: string, file = ""): Promise<FlowAnswer | null> {
try {
const q = new URLSearchParams({ key, ...(file ? { file } : {}) });
const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" });
if (!r.ok) return null;
const got = (await r.json()) as Partial<FlowAnswer>;
return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null;
} catch {
return null;
}
}
const loadFlowMods = () =>
Promise.all([
import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"),
import("./M01_MasterData_UI_LogicLab_FlowView"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Model"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Api"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"),
]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail }));
/** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */
export async function emptyFlow(name: string, unit: string): Promise<CfLogic> {
return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit);
}
export interface LogicFlowOpts {
split: SplitHandle;
flow: CfLogic;
title: string;
readOnly: boolean;
/** 변환 알림 — 패널 위 한 줄 */
notes: string[];
converted: boolean;
/** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */
leftHost(): HTMLElement | null;
onChange(lg: CfLogic): void;
}
export interface LogicFlow {
/** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */
set(lg: CfLogic): void;
/** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */
redrawLeft(): void;
destroy(): void;
}
/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */
export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
const M = await loadFlowMods();
const host = el("div", { className: "m01lab-flow__panel" });
const note = el("p", { className: "m01lab-flow__note" });
const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes];
note.textContent = notes.join(" · ");
note.hidden = !notes.length;
opts.split.right.replaceChildren(note, host);
let lg = opts.flow;
let values: CfValues = {};
let valueSig = "";
let logics: Record<string, CfLogic> = {};
let logicSig = "";
let gone = false;
/** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */
const getLogic = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null);
const left = (): void => {
const at = opts.leftHost();
if (!at || gone) return;
M.view.buildFlowView(at, lg, values, { logics });
};
/** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */
const refresh = (): void => {
let ports: Record<string, Record<string, unknown>> = {};
try {
ports = M.ev.evalLogic(lg, values, { logics }).ports;
} catch {
/* 셈이 막혀도 물음은 보냄 */
}
const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null;
const asks = M.detail.queriesOf(lg, pill, M.model.elementPort);
const sig = JSON.stringify(asks.map((a) => a.q));
if (sig !== valueSig) {
valueSig = sig;
void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then(
(got) => {
if (sig !== valueSig || gone) return;
values = got.values;
left();
refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더
},
() => undefined,
);
}
const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
if (keys !== logicSig) {
logicSig = keys;
void M.detail.loadLogics(lg, getLogic).then((got) => {
if (keys !== logicSig || gone) return;
logics = got.logics;
left();
});
}
};
const panel = M.panel.mountFlowPanel(host, {
logic: lg,
title: opts.title,
readOnly: opts.readOnly,
logics: getLogic,
onResize: (px) => opts.split.setRight(px),
onChange: (next) => {
lg = next;
opts.onChange(next);
left();
refresh();
},
});
left();
refresh();
return {
set: (next) => {
lg = next;
panel.set(next);
left();
refresh();
},
redrawLeft: left,
destroy: () => {
gone = true;
panel.destroy();
opts.split.setRight(null);
opts.split.right.replaceChildren();
},
};
}
@@ -0,0 +1,122 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_FlowRows.ts
* 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어
* 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함.
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py
* ========================================================================== */
import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
export type FlowGroupKey = "labor" | "material" | "cost" | "plain";
/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */
export const GROUP_OF: Record<CfCost, FlowGroupKey> = {
노무비: "labor",
재료비: "material",
경비: "cost",
값: "plain",
};
export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"];
const COST_OF: Record<Exclude<FlowGroupKey, "plain">, "노무비" | "재료비" | "경비"> = {
labor: "노무비",
material: "재료비",
cost: "경비",
};
export interface FlowLine {
box: string;
name: string;
unit: string;
value: number | string | boolean | null;
/** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */
text: string;
}
export interface FlowGroup {
key: FlowGroupKey;
lines: FlowLine[];
/** 비용 묶음만 — 합계 끝수를 적용한 소계 */
sub: number | null;
subText: string;
}
export interface FlowFunc {
box: string;
name: string;
unit: string;
value: unknown;
text: string;
}
export interface FlowRows {
groups: FlowGroup[];
/** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */
total: number;
totalText: string;
funcs: FlowFunc[];
errors: string[];
}
const plain = (v: unknown): string =>
v === null || v === undefined || Array.isArray(v)
? "—"
: typeof v === "number"
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
: String(v);
/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */
export function flowRows(
lg: CfLogic,
ev: CfEvalResult,
round: { item: CfRound; total: CfRound },
): FlowRows {
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
const groups = GROUP_ORDER.map((key): FlowGroup => {
const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key);
const lines = mine.map((b): FlowLine => {
const v = ev.outputs[b.id] ?? null;
const r = b.output?.round ?? (key === "plain" ? undefined : round.item);
return {
box: b.id,
name: b.name,
unit: b.output?.unit ?? b.unit,
value: v,
text: typeof v === "number" ? showRounded(v, r) : plain(v),
};
});
const raw = key === "plain" ? null : ev.cost[COST_OF[key]];
const sub = raw === null ? null : roundBy(round.total, raw);
return {
key,
lines,
sub,
subText: sub === null ? "" : showRounded(sub, round.total),
};
}).filter((g) => g.lines.length);
const total = roundBy(round.total, ev.cost.계);
const funcs = lg.boxes
.filter((b) => b.kind === "function" && b.func)
.map((b): FlowFunc => {
const out = b.func!.output;
const v = ev.ports[b.id]?.[out.name];
return {
box: b.id,
name: b.name,
unit: out.unit || b.unit,
value: v,
text:
typeof v === "number"
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
: plain(v),
};
});
return {
groups,
total,
totalText: showRounded(total, round.total),
funcs,
errors: ev.errors,
};
}
@@ -0,0 +1,30 @@
.m01flow__group {
margin: 0 0 var(--spacing-12);
}
.m01flow__block {
margin: 0 0 var(--spacing-8);
}
.m01flow__func {
margin: 0 0 var(--spacing-4);
color: var(--color-text-body);
font-weight: 600;
}
.m01flow__branch {
margin: 0 0 0 var(--spacing-16);
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01flow__branch--now {
color: var(--color-text-body);
font-weight: 600;
}
.m01flow__warn {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,190 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_FlowView.ts
* 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다
* 식 두 줄 — sub2 `formulaLines`). 기본정보 · 시험 계산은 Detail 이 둠.
* 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름.
* 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름.
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import {
evalLogic,
type CfEvalOptions,
type CfEvalResult,
type CfValues,
} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
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,
type FlowFunc,
type FlowGroup,
type FlowGroupKey,
} from "./M01_MasterData_UI_LogicLab_FlowRows";
import "./M01_MasterData_UI_LogicLab_FlowView.css";
const TEXT = {
Detail: ["단가산출 상세", "Unit-price detail"],
Formula: ["텍스트 수식", "Text formulas"],
Labor: ["인력 · 노무비", "Labor · labor cost"],
Material: ["재료 · 재료비", "Material · material cost"],
Cost: ["경비", "Expense"],
Plain: ["값(비용 아님)", "Values (not costs)"],
HName: ["이름", "Name"],
HUnit: ["단위", "Unit"],
HValue: ["값", "Value"],
Sub: ["소계", "Subtotal"],
Total: ["계", "Total"],
TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"],
None: ["출력 상자가 없음", "No output box"],
NoFunc: ["함수 상자가 없음", "No function box"],
} as const;
type TextKey = keyof typeof TEXT;
const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const GROUP_TITLE: Record<FlowGroupKey, TextKey> = {
labor: "Labor",
material: "Material",
cost: "Cost",
plain: "Plain",
};
const section = (title: string, body: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01-logic__section m01lab__box",
children: [
el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }),
...body,
],
});
const groupTable = (g: FlowGroup): HTMLElement => {
const sub =
g.sub === null
? []
: [
el("tr", {
className: "m01lab__sub",
children: [
el("td", { text: tf("Sub"), attrs: { colspan: "2" } }),
el("td", { className: "m01-logic__money", text: g.subText }),
],
}),
];
return el("div", {
className: "m01flow__group",
children: [
el("h4", { text: tf(GROUP_TITLE[g.key]) }),
el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", {
children: [
...g.lines.map((l) =>
el("tr", {
children: [
el("td", { text: l.name }),
el("td", { text: l.unit }),
el("td", { className: "m01-logic__money", text: l.text }),
],
}),
),
...sub,
],
}),
],
}),
],
}),
],
});
};
/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */
function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement {
const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : "";
const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` });
if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] });
return el("div", {
className: "m01flow__block",
children: [
head,
...lines.flatMap((l) => [
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }),
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }),
...(l.branches ?? []).map((b) =>
el("p", {
className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`,
text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`,
}),
),
]),
],
});
}
/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */
export function buildFlowView(
host: HTMLElement,
lg: CfLogic,
values: CfValues,
opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {},
): void {
let ev: CfEvalResult;
try {
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
} catch (e) {
host.replaceChildren(
section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]),
);
return;
}
const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() });
const detail = section(tf("Detail"), [
...(rows.groups.length
? rows.groups.map(groupTable)
: [el("p", { className: "m01-logic__muted", text: tf("None") })]),
el("p", {
className: "m01lab__total",
attrs: { "data-sum": String(rows.total) },
text: `${tf("Total")} ${rows.totalText}`,
}),
el("p", { className: "m01-logic__muted", text: tf("TotalNote") }),
...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })),
]);
/** 함수 상자마다 식 두 줄(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);
}
@@ -1,105 +0,0 @@
/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */
.m01lab-new {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01lab-new__step {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__text {
width: 100%;
min-height: 140px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
color: var(--color-text);
font-family: ui-monospace, Consolas, monospace;
font-size: var(--text-body);
resize: vertical;
}
.m01lab-new__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m01lab-new__vars {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01lab-new__var {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01lab-new__var-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
}
.m01lab-new__var-body {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8);
margin-top: var(--spacing-8);
}
.m01lab-new__wide {
flex: 1 1 220px;
}
.m01lab-new__picked {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__src {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-paper);
overflow-x: auto;
}
.m01lab-new__no {
flex: 0 0 auto;
color: var(--color-text-secondary);
}
.m01lab-new__src mark {
background: var(--color-danger);
color: var(--color-bg);
border-radius: 2px;
}
.m01lab-new__bad {
color: var(--color-danger);
}
.m01lab-new__soft {
color: var(--color-text-secondary);
}
.m01lab-new__calc {
min-width: 0;
}
@@ -1,272 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New.ts
* 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장)
* ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름
* ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX)
* 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`)
* ========================================================================== */
import {
createButton,
createInputField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
draftLogic,
type DraftAnswer,
type DraftDecided,
type DraftHead,
type DraftProblem,
type DraftVar,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var";
import "./M01_MasterData_UI_LogicLab_New.css";
/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */
const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요
넓이 = 길이 * 폭
인부 = 넓이 * 품
잡재료비 = 노무비 * 0.05
공사비 = 인부 * 보통인부 + 잡재료비
`;
export interface NewOptions {
/** 저장 끝 — 새 자체 로직의 키 */
onSaved: (key: string) => void;
onClose: () => void;
}
export function buildNewLogic(opts: NewOptions): HTMLElement {
const decided: Record<string, DraftDecided> = {};
const rows = new Map<string, VarRow>();
const values: Record<string, string> = {};
let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림
let answer: DraftAnswer | null = null;
let calcSig = "";
let seq = 0;
const text = el("textarea", {
className: "m01lab-new__text",
attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") },
});
text.value = SAMPLE;
const stale = el("p", {
className: "m01-logic__muted",
text: tn("Stale"),
attrs: { hidden: "" },
});
const varsHost = el("div", { className: "m01lab-new__vars" });
const problemHost = el("div", { className: "m01lab-new__problems" });
const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" });
const nameBox = createInputField({
type: "text",
label: tn("Name"),
onInput: () => onEdit(),
});
nameBox.input.setAttribute("data-key", "name");
const unitBox = createInputField({
type: "text",
label: tn("ResultUnit"),
placeholder: "원/㎡",
onInput: () => onEdit(),
});
unitBox.input.setAttribute("data-key", "unit");
const ownerBox = createInputField({ type: "text", label: tn("Owner") });
const state = el("p", { className: "m01-logic__muted" });
const save = createButton({ label: tn("Save"), onClick: () => void doSave() });
save.setAttribute("data-act", "save");
save.disabled = true;
const head = (): DraftHead | undefined => {
const 이름 = nameBox.input.value.trim();
const 결과단위 = unitBox.input.value.trim();
return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined;
};
/** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */
const drawProblems = (problems: DraftProblem[]): void => {
if (!analyzed) return problemHost.replaceChildren();
const lines = analyzed.split("\n");
const flagged = new Map<number, DraftProblem[]>();
for (const p of problems) {
if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]);
}
const shown = [...flagged.keys()]
.sort((a, b) => a - b)
.filter((n) => n <= lines.length)
.map((n) => {
const raw = lines[n - 1];
const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1;
return el("div", {
className: "m01lab-new__src",
children: [
el("span", { className: "m01lab-new__no", text: String(n) }),
at >= 0
? el("code", {
children: [
raw.slice(0, at),
el("mark", { text: raw.slice(at, at + 1) }),
raw.slice(at + 1),
],
})
: el("code", { text: raw }),
],
});
});
const items = problems.map((p) =>
el("li", {
className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad",
attrs: { "data-kind": p.갈래 },
text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`,
}),
);
problemHost.replaceChildren(
el("h4", { text: tn("Problems") }),
...(items.length
? [...shown, el("ul", { children: items })]
: [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]),
);
};
const drawCalc = (): void => {
const row = answer?.logic ?? null;
save.disabled = !row || (answer?.문제.length ?? 1) > 0;
state.textContent = !answer
? ""
: answer.문제.some((p) => p.갈래 !== "덜정함")
? tn("SaveBlocked")
: !head()
? tn("NeedHead")
: answer.다됨
? ""
: tn("Undecided");
if (!row) {
calcSig = "";
calcHost.replaceChildren();
return;
}
const sig = JSON.stringify(row.입력 ?? []);
if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림
calcSig = sig;
buildCalc(calcHost, {
savedKey: null,
row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄
dirty: () => true,
values,
onLines: () => undefined,
});
};
const drawVars = (vars: DraftVar[]): void => {
rows.clear();
const shown = vars.filter((v) => v.갈래 !== "비목합");
for (const name of Object.keys(decided)) {
if (!shown.some((v) => v.이름 === name)) delete decided[name];
}
const result = answer?.결과 ?? "";
const order = [
...shown.filter((v) => v.갈래 === "미정"),
...shown.filter((v) => v.갈래 !== "미정"),
];
for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit }));
varsHost.replaceChildren(...[...rows.values()].map((r) => r.root));
};
const evaluate = async (rebuild: boolean): Promise<void> => {
if (!analyzed) return;
const mine = ++seq;
try {
const got = await draftLogic({ text: analyzed, decided, head: head() });
if (mine !== seq) return; // 최신 응답만
answer = got;
if (rebuild) drawVars(got.변수);
for (const v of got.변수) rows.get(v.이름)?.update(v);
drawProblems(got.문제);
drawCalc();
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
let timer = 0;
function onEdit(): void {
window.clearTimeout(timer);
timer = window.setTimeout(() => void evaluate(false), 250);
}
const analyzeButton = createButton({
label: tn("Analyze"),
onClick: () => {
analyzed = text.value;
stale.hidden = true;
void evaluate(true);
},
});
analyzeButton.setAttribute("data-act", "analyze");
text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed));
async function doSave(): Promise<void> {
const h = head();
if (!h) return void showToast(tn("NeedHead"), "info");
save.disabled = true;
showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림
try {
const owner = ownerBox.input.value.trim();
const done = await draftLogic({
text: analyzed,
decided,
head: h,
save: true,
...(owner ? { owner } : {}),
});
showToast(tn("Saved", { v: done.key ?? "" }), "success");
if (done.key) opts.onSaved(done.key);
} catch (error) {
const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null;
if (detail?.문제) drawProblems(detail.문제);
else showToast(error instanceof Error ? error.message : tn("Failed"), "error");
save.disabled = false;
} finally {
hideLoadingOverlay();
}
}
const section = (title: string, ...children: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01lab__box m01lab-new__step",
children: [el("h3", { text: title }), ...children],
});
return el("div", {
className: "m01lab-new",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h2", { text: tn("Title") }),
createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }),
],
}),
section(
tn("Step1"),
el("p", { className: "m01-logic__muted", text: tn("Guide") }),
text,
el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }),
),
section(tn("Step2"), varsHost, problemHost),
section(
tn("Step3"),
el("div", {
className: "m01lab-new__row",
children: [nameBox.root, unitBox.root, ownerBox.root],
}),
),
section(tn("Step4"), calcHost, state, save),
],
});
}
@@ -1,218 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Find.ts
* 새 로직 만들기의 고르기 창 둘
* ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기
* ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubsAll,
type FileInfo,
type SubInfo,
} from "./M01_MasterData_Api_Fetch";
import type { DraftFind } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface PickedElement {
ref: string;
/** 이름 + 규격 */
name: string;
/** 수량에 쓰는 단위(「원/일」 → 「일」) */
unit: string;
group: string;
}
const GROUPS = ["인력", "재료", "기계"];
const SHOWN = 50;
/** 창 뼈대 — 제목 · 닫기 · 몸통 */
function shell(title: string, body: HTMLElement[]): { close: () => void } {
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": title },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: title }),
createButton({ label: tn("Close"), variant: "ghost", onClick: close }),
],
}),
...body,
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
return { close };
}
/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */
async function fileOf(group: string): Promise<FileInfo | undefined> {
const files = await fetchFiles(group);
return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0];
}
const options = (values: string[]): { value: string; text: string }[] => [
{ value: "", text: tn("All") },
...values.map((v) => ({ value: v, text: v })),
];
export function openElementPick(done: (picked: PickedElement) => void): void {
let file: FileInfo | undefined;
let subs: SubInfo[] = [];
const groupBox = createSelectField({
options: GROUPS.map((g) => ({ value: g, text: g })),
value: GROUPS[0],
compact: true,
label: tn("Group"),
});
const subBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Sub"),
});
const detailBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Detail"),
});
const searchField = createInputField({ type: "search", placeholder: tn("Search") });
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const win = shell(tn("K_master"), [
el("div", {
className: "m01-logic__pick-bar",
children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count],
}),
list,
]);
let seq = 0;
const load = async (): Promise<void> => {
if (!file) return;
const mine = ++seq;
try {
const got = await fetchRows(
file.file,
1,
SHOWN,
searchField.input.value.trim(),
false,
subBox.select.value,
detailBox.select.value,
);
if (mine !== seq) return;
count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) });
list.replaceChildren(
...got.rows.map((row) => {
const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
const unitText = String(row["단위"] ?? "");
const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? "");
const button = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-ref": String(row["키"]) },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: name }),
el("span", { className: "m01-logic__muted", text: String(row["키"]) }),
],
}),
el("span", {
className: "m01-logic__money",
text: `${formatNumber(row["값"])} ${unitText}`.trim(),
}),
],
});
button.addEventListener("click", () => {
win.close();
done({ ref: String(row["키"]), name, unit, group: groupBox.select.value });
});
return button;
}),
);
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
const changeGroup = async (): Promise<void> => {
file = await fileOf(groupBox.select.value);
subs = file ? (await fetchSubsAll({ file: file.file })).subs : [];
subBox.setOptions(options(subs.map((s) => s.name)), "");
detailBox.setOptions(options([]), "");
detailBox.select.disabled = true;
await load();
};
groupBox.select.addEventListener("change", () => void changeGroup());
subBox.select.addEventListener("change", () => {
const details = subs.find((s) => s.name === subBox.select.value)?.details ?? [];
detailBox.setOptions(options(details), "");
detailBox.select.disabled = details.length === 0;
void load();
});
detailBox.select.addEventListener("change", () => void load());
let timer = 0;
searchField.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void load(), 250);
});
void changeGroup();
}
/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */
export function openTablePick(done: (find: DraftFind, label: string) => void): void {
openPicker("table", "소요량", (item, column) => {
void (async () => {
const table = await loadTable(item.ref);
const conds = Object.keys(table?.조건 ?? {});
const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`;
const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record<string, string> };
if (!conds.length) {
done(find, label);
return;
}
const inputs = conds.map((c) => {
const box = createInputField({ type: "text", value: c });
box.input.setAttribute("data-cond", c);
return { c, box };
});
const win = shell(tn("TableTitle"), [
el("p", { className: "m01-logic__muted", text: label }),
el("p", { text: tn("Cond") }),
...inputs.map(({ c, box }) =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: c }), box.root],
}),
),
createButton({
label: tn("Ok"),
onClick: () => {
for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c;
win.close();
done(find, label);
},
}),
]);
})();
});
}
@@ -1,71 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Text.ts
* 「식으로 새 로직 만들기」 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Open: ["식으로 새로 만들기", "New from formula"],
Title: ["식으로 새 로직 만들기", "New logic from a formula"],
Close: ["닫기", "Close"],
Step1: ["① 식 적기", "① Write the formula"],
Guide: [
"한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀",
"One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped",
],
Analyze: ["분석", "Analyze"],
Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"],
Step2: ["② 변수 정하기", "② Decide each variable"],
Step3: ["③ 이름·단위", "③ Name and unit"],
Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"],
Problems: ["고칠 곳", "To fix"],
Line: ["{n}번 줄", "Line {n}"],
AllGood: ["검사에 걸린 것 없음", "No problems found"],
Result: ["결과", "Result"],
Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"],
Kind: ["무엇인가", "What is it"],
K_master: ["마스터 요소", "Master element"],
K_input: ["설계 입력", "Design input"],
K_fixed: ["고정값", "Fixed value"],
K_table: ["표 찾기", "Table lookup"],
Pick: ["고르기", "Pick"],
Repick: ["다시 고르기", "Pick again"],
NotPicked: ["아직 안 골랐음", "Not picked"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost item"],
CostNone: ["넣지 않음", "None"],
CostExtra: ["덧줄 비목", "Extra-line cost item"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Derived: ["식으로 정해짐", "Set by the formula"],
SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"],
Name: ["로직 이름", "Logic name"],
ResultUnit: ["결과 단위", "Result unit"],
Owner: ["소유", "Owner"],
Save: ["저장", "Save"],
Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"],
SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"],
NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"],
Group: ["종류", "Kind"],
Sub: ["구분", "Group"],
Detail: ["상세구분", "Detail"],
All: ["(전체)", "(All)"],
Search: ["이름 찾기", "Search name"],
Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"],
TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"],
TablePick: ["표 고르기", "Pick a table"],
TableCol: ["값 칸", "Value column"],
Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
Failed: ["못 함", "Failed"],
} as const satisfies Record<string, readonly [string, string]>;
export type NewTextKey = keyof typeof TEXT;
export function tn(key: NewTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
@@ -1,178 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Var.ts
* 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름
* 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api";
import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface VarCtx {
decided: Record<string, DraftDecided>;
/** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */
onEdit: () => void;
}
export interface VarRow {
root: HTMLElement;
/** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */
update: (v: DraftVar) => void;
}
const KINDS: [string, () => string][] = [
["마스터요소", () => tn("K_master")],
["설계입력", () => tn("K_input")],
["고정값", () => tn("K_fixed")],
["표찾기", () => tn("K_table")],
];
const COSTS = ["노무비", "재료비", "경비"];
/** 요소 종류 → 비목 첫 제안 */
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
const costOptions = (): { value: string; text: string }[] => [
{ value: "", text: tn("CostNone") },
...COSTS.map((c) => ({ value: c, text: c })),
];
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
});
export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow {
const name = v.이름;
const info = el("span", { className: "m01-logic__muted m01lab-new__derived" });
const body = el("div", { className: "m01lab-new__var-body" });
const root = el("div", {
className: "m01lab-new__var",
attrs: { "data-var": name },
children: [
el("div", {
className: "m01lab-new__var-head",
children: [el("strong", { text: name }), info],
}),
body,
],
});
const now = (): DraftDecided => (ctx.decided[name] ??= {});
const set = (patch: DraftDecided): void => {
ctx.decided[name] = { ...now(), ...patch };
for (const [k, val] of Object.entries(ctx.decided[name])) {
if (val === "" || val === undefined) delete (ctx.decided[name] as Record<string, unknown>)[k];
}
ctx.onEdit();
};
const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => {
const box = createInputField({
type: "text",
value: now()[key] ?? "",
onInput: (val) => set({ [key]: val.trim() }),
});
box.input.setAttribute("data-key", key);
const cell = field(label, box.root);
if (wide) cell.classList.add("m01lab-new__wide");
return cell;
};
let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글
const draw = (): void => {
const kind = now().무엇 ?? "";
if (v.갈래 === "비목합") {
body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") }));
return;
}
if (v.갈래 === "정의됨") {
if (isResult) return body.replaceChildren();
const cost = createSelectField({
options: costOptions(),
value: now().비목 ?? "",
compact: true,
label: tn("CostExtra"),
onChange: (val) => set({ 비목: val }),
});
body.replaceChildren(cost.root);
return;
}
const kindBox = createSelectField({
options: [
{ value: "", text: "—" },
...KINDS.map(([value, label]) => ({ value, text: label() })),
],
value: kind,
compact: true,
label: tn("Kind"),
onChange: (val) => {
ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {};
picked = "";
draw();
ctx.onEdit();
},
});
const parts: HTMLElement[] = [kindBox.root];
if (kind === "마스터요소") {
const cost = createSelectField({
options: costOptions(),
value: now().비목 ?? "",
compact: true,
label: tn("Cost"),
onChange: (val) => set({ 비목: val }),
});
const choose = createButton({
label: now().요소 ? tn("Repick") : tn("Pick"),
variant: "ghost",
onClick: () =>
openElementPick((p) => {
picked = `${p.name} · ${p.ref}`;
set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 });
draw();
}),
});
choose.setAttribute("data-act", "pick-element");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
cost.root,
);
} else if (kind === "설계입력") {
parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true));
} else if (kind === "고정값") {
parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위"));
} else if (kind === "표찾기") {
const choose = createButton({
label: now().찾기 ? tn("Repick") : tn("TablePick"),
variant: "ghost",
onClick: () =>
openTablePick((find, label) => {
picked = label;
set({ 찾기: find });
draw();
}),
});
choose.setAttribute("data-act", "pick-table");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
);
}
body.replaceChildren(...parts);
};
draw();
const update = (next: DraftVar): void => {
v = next;
info.textContent =
next.갈래 === "정의됨"
? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}`
: (next.단위 ?? "");
};
update(v);
return { root, update };
}
@@ -81,7 +81,7 @@ const TEXT = {
"지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음",
"No row matches the current values — change them, or this table is on an unused branch",
],
Copy_Open: ["본떠 만들기", "Copy as mine"],
Copy_Open: ["로직 복사/생성", "Copy / create logic"],
Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"],
Copy_Title: ["복사해서 만들기", "Copy to edit"],
Copy_Name: ["새 이름", "New name"],
+19
View File
@@ -206,6 +206,25 @@ export async function cfLogicValue(
return send("/logic-value", { key, designs });
}
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
export async function cfLogicFlow(
file: string,
key: string,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
credentials: "include",
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data as { flow: CfLogic; converted: boolean; notes: string[] };
}
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
const qs = new URLSearchParams({ key }).toString();
@@ -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 })],
}),
);
}
+21 -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+)$")
@@ -130,6 +132,15 @@ def _element_value(
f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈"
)
got = dv.slot_price({"mode": "fixed", "ref": el["default"]})
elif group in dv.GROUP_RULE and mode == "rule": # 인력 · 기계 · 유가전력 조건 찾기
rule = el.get("rule") or {}
src = rule.get("from") or {}
slot = {"field": rule.get("field"), "op": rule.get("op"), "scope": el.get("scope"),
"ref": el.get("ref"), "unit": el.get("unit")} # fmt: skip
got = dv.group_find(group, slot, ports.get(src.get("box"), {}).get(src.get("name")))
if not got["ok"] and el.get("default"):
errors.append(f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본으로 셈")
got = dv.one_value({"group": group, "ref": el["default"], "unit": el.get("unit")})
elif given is not None:
return given
else:
@@ -188,6 +199,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"]]
@@ -565,7 +582,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:
+42 -51
View File
@@ -23,6 +23,9 @@ import {
restParts,
linkLabel,
midOf,
pillInfo,
svgAdd as add,
svgLabel as label,
pillsOf,
resolveDrop,
showValue,
@@ -46,6 +49,7 @@ interface HostState {
panX: number;
panY: number;
fitted: boolean;
fitKey?: number;
watch?: ResizeObserver;
}
@@ -79,6 +83,9 @@ export function renderDiagram(
fitted: false,
};
if (opts.selected !== undefined) st.selected = opts.selected;
if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey)
[st.fitKey, st.fitted] = [opts.fitKey, false];
const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨)
if (opts.selectedPill !== undefined) st.pill = opts.selectedPill;
states.set(host, st);
const values = opts.values ?? {};
@@ -243,7 +250,7 @@ export function renderDiagram(
className: "m02-dfdg__ins",
children: [
...ins.map((p) => pillEl(b, p, "in")),
...(b.kind === "function" ? [plusSlot(b)] : []),
...(b.kind === "function" && !ro ? [plusSlot(b)] : []),
],
}),
el("div", {
@@ -282,13 +289,13 @@ export function renderDiagram(
ArrowUp: [0, -step],
ArrowDown: [0, step],
}[ev.key];
if (d && st.selected === b.id) {
if (d && st.selected === b.id && !ro) {
ev.preventDefault();
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
}
});
head.addEventListener("pointerdown", (ev) => {
if (ev.button === 0 && !spaceDown) dragBox(ev, b, node);
if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node);
});
return node;
};
@@ -307,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 }));
Object.assign(
st,
fitView(
rects,
{ w: r.width, h: r.height },
40,
button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin,
),
);
applyView();
drawLines();
};
@@ -350,15 +366,8 @@ export function renderDiagram(
);
} else if (st.pill) {
const p = st.pill;
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port;
const where = lg.links
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
.map((l) => endName(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") }));
if (p.dir === "out")
tools.push(el("span", { text: pillInfo(lg, p, values) }));
if (p.dir === "out" && !ro)
tools.push(
sm(
"Connect",
@@ -371,9 +380,9 @@ export function renderDiagram(
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: rule ? `${line} · ${rule}` : line }),
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]),
);
} else if (selBox) {
} else if (selBox && !ro) {
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
}
const legend =
@@ -388,18 +397,26 @@ export function renderDiagram(
className: "m02-dfdg__bar",
children: [
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
...(ro
? []
: [
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
]),
el("span", { className: "m02-dfdg__gap" }),
...legend,
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
zoomLabel,
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
sm("Fit", fit),
sm("Arrange", () => {
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
}),
sm("Fit", () => fit(true)),
...(ro
? []
: [
sm("Arrange", () =>
Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)),
),
]),
],
});
const root = el("div", {
@@ -426,7 +443,7 @@ export function renderDiagram(
st.pill = null;
redraw();
}
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
if (ro || ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
if (selLink) on.unlink(selLink.id);
else if (selBox) void askRemove(selBox);
});
@@ -477,32 +494,6 @@ export function renderDiagram(
const right = (dir === "out") === (side === "near");
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
};
const add = <K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attrs: Record<string, string>,
) => {
const n = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.append(n);
return n;
};
const label = (parent: Element, x: number, y: number, text: string, cls: string): number => {
const tag = add(parent, "g", { class: cls });
const box = add(tag, "rect", { rx: "4", ry: "4" });
const t = add(tag, "text", {
x: x.toFixed(1),
y: y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = text;
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
return w;
};
const defs = add(svg, "defs", {});
const arrow = add(defs, "marker", {
id: "m02-dfdg-arrow",
@@ -657,7 +648,7 @@ export function renderDiagram(
}
function dragLink(ev: PointerEvent, from: CfPort): void {
if (ev.button !== 0 || spaceDown) return;
if (ev.button !== 0 || spaceDown || ro) return;
ev.stopPropagation();
ev.preventDefault();
const pill = ev.currentTarget as HTMLElement;
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_DF_Diagram_Parts.ts
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`.
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -118,6 +118,14 @@ export interface DiagramOpts {
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
/** 끊긴 곳 — Eval breaks */
breaks?: CfBreak[];
/** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */
readOnly?: boolean;
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
fitKey?: number;
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
fitMin?: number;
/** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */
fitAllMin?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -189,13 +197,14 @@ export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
min = FIT_MIN,
): { zoom: number; panX: number; panY: number } {
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
const place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
@@ -330,6 +339,25 @@ export function restParts(
return { links, pills, boxes };
}
/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */
export function pillInfo(
lg: CfLogic,
p: DfPillSel,
values: Record<string, Record<string, unknown>>,
): string {
const name = (e: CfPort): string =>
`${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`;
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port;
const where = lg.links
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
.map((l) => name(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""]
.filter(Boolean)
.join(" · ");
}
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */
@@ -360,3 +388,37 @@ export function follow(
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */
const SVG_NS = "http://www.w3.org/2000/svg";
/** SVG 마디 하나 붙이기 */
export function svgAdd<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attrs: Record<string, string>,
): SVGElementTagNameMap[K] {
const n = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.append(n);
return n;
}
/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */
export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number {
const tag = svgAdd(parent, "g", { class: cls });
const box = svgAdd(tag, "rect", { rx: "4", ry: "4" });
const t = svgAdd(tag, "text", {
x: x.toFixed(1),
y: y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = text;
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
return w;
}
+1 -1
View File
@@ -70,7 +70,7 @@
/* 시작 화면 */
.m02-df__start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-24);
}
+2 -24
View File
@@ -47,12 +47,11 @@ const loadMods = () =>
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
import("./M02_DF_PillPanel"),
import("./M02_DF_LinkPanel"),
import("./M02_DF_Standards"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({
]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({
model,
ev,
api,
@@ -60,7 +59,6 @@ const loadMods = () =>
func,
output,
summary,
sample,
input,
pill,
line,
@@ -78,7 +76,6 @@ const TEXT = {
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
@@ -217,7 +214,6 @@ function openDataFlow(): void {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
const sampleHost = el("div", { className: "m02-df__list" });
body.replaceChildren(
el("div", {
className: "m02-df__start",
@@ -232,28 +228,10 @@ function openDataFlow(): void {
mineHost,
],
}),
el("section", {
className: "m02-df__start-col",
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
}),
],
}),
);
const M = await mods;
sampleHost.replaceChildren(
...M.sample.sampleList().map((s) =>
listRow(
s.name,
s.resultUnit,
() =>
run(async (M2) => {
const lg = M2.sample.sampleLoad(s.id);
if (lg) await showPaper(M2, lg, false);
}),
s.note,
),
),
);
try {
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
mineHost.replaceChildren(
@@ -379,7 +357,7 @@ function openDataFlow(): void {
if (sig === logicSig) return;
logicSig = sig;
const get = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key);
key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
@@ -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);
@@ -24,6 +24,9 @@ import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Ap
import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model";
import {
GROUPS,
RULE_GROUPS,
matchesRow,
removeElementNamed,
M01_OF,
addElement,
elementOf,
@@ -36,6 +39,7 @@ import {
type M01Row,
} from "./M02_DF_InputPanel_Build";
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
import { renderGroupRule } from "./M02_DF_InputPanel_Rule";
import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
import { renderMaterial } from "./M02_DF_InputPanel_Material";
import { renderTablePick } from "./M02_DF_InputPanel_Table";
@@ -63,6 +67,10 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
Variable: ["변수", "Variable"],
ModeFixed: ["고정", "Fixed"],
ModeRule: ["조건 찾기", "Find by rule"],
RuleTag: ["조건 찾기", "Rule"],
RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"],
Logic: ["다른 로직", "Another logic"],
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
@@ -193,10 +201,52 @@ function renderGroup(
on: { change(lg: CfLogic): void },
): void {
const chosen = el("div", { className: "m02-dfip__chosen" });
const find = el("div", { className: "m02-dfip__find" });
host.replaceChildren(chosen, find, newForm(lg, box, group, on));
const body = el("div", { className: "m02-dfip__stack" });
let mode: "fixed" | "rule" = "fixed";
const draw = (): void => {
if (mode === "rule")
renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on);
else {
const find = el("div", { className: "m02-dfip__find" });
body.replaceChildren(find, newForm(lg, box, group, on));
void drawFind(find, lg, box, group, on);
}
};
// 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만
const chips = el("div", { className: "m02-dfip__chips" });
const drawChips = (): void =>
chips.replaceChildren(
...(["fixed", "rule"] as const).map((m) =>
createButton({
label: m === "fixed" ? t("ModeFixed") : t("ModeRule"),
variant: mode === m ? "filled" : "pill",
size: "sm",
onClick: () => ((mode = m), drawChips(), draw()),
}),
),
);
host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body);
drawChosen(chosen, lg, box, group, on);
void drawFind(find, lg, box, group, on);
if (RULE_GROUPS.includes(group)) drawChips();
draw();
}
/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */
async function rulePreview(
group: CfGroup,
field: "이름" | "키",
value: unknown,
): Promise<{ found: number; names: string[]; unit: string }> {
if (value === null || value === undefined || value === "")
return { found: 0, names: [], unit: "" };
const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", "");
const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value));
const units = new Set(hit.map((r) => elementOf(group, r).unit));
return {
found: hit.length,
names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")),
unit: units.size === 1 ? [...units][0] : "",
};
}
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */
@@ -214,26 +264,48 @@ function drawChosen(
...(els.length
? els.map((e) => {
const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") });
values.set(e.ref, val);
const rule = e.mode === "rule";
if (!rule) values.set(e.ref, val);
return el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: e.name }),
el("span", {
className: "m02-dfip__name",
children: [
...(rule ? [el("span", { className: "m02-dfip__tag", text: t("RuleTag") })] : []),
el("strong", { text: ` ${e.name}` }),
...(rule && e.rule
? [
el("div", {
className: "m02-dfip__muted",
text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }),
}),
]
: []),
],
}),
val,
el("span", { className: "m02-dfip__muted", text: e.unit || "-" }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeElement(lg, box.id, e.ref)),
onClick: () =>
on.change(
rule
? removeElementNamed(lg, box.id, e.name)
: removeElement(lg, box.id, e.ref),
),
}),
],
});
})
: [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]),
);
if (!els.length) return;
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
const items = els
.filter((e) => e.mode !== "rule")
.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
if (!items.length) return;
void apiValues(items, lg.userElements).then(
(answers) =>
answers.forEach((a) => {
@@ -162,16 +162,21 @@ export function matches(
return rule.op === "같음" ? target === v : target.includes(v);
}
/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */
/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */
export function catchNote(
found: number,
firstName = "",
noun = "재료",
): { kind: "none" | "one" | "many"; text: string } {
if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" };
if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` };
if (found === 0)
return {
kind: "none",
text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`,
};
if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` };
return {
kind: "many",
text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
};
}
@@ -236,19 +241,28 @@ export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string
return null;
}
/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리) */
/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약)
* examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */
export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
const outputs = other.boxes
.filter((b) => b.kind === "output")
.map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" }));
const designs: { name: string; unit: string }[] = [];
const examples: Record<string, number | string> = {};
const addDesign = (name: string, unit: string, example?: number | string | null): void => {
designs.push({ name, unit: unit || "-" });
if (example !== null && example !== undefined && example !== "") examples[name] = example;
};
for (const b of other.boxes) {
if (b.kind !== "input" || !b.input) continue;
if (b.input.mode === "variable")
for (const v of b.input.variables) designs.push({ name: v.name, unit: v.unit || "-" });
for (const e of b.input.elements)
if (e.mode === "choice" || e.mode === "rule")
designs.push({ name: e.slot || e.name, unit: e.unit || "-" });
for (const v of b.input?.variables ?? b.func?.variables ?? [])
addDesign(v.name, v.unit, v.example);
if (b.input?.mode === "group")
for (const e of b.input.elements)
if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit);
if (b.input?.mode === "logic")
for (const d of b.input.logic?.designs ?? [])
if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name))
addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]);
}
const seen = new Set<string>();
return {
@@ -256,9 +270,115 @@ export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
name: other.name,
outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)),
designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))),
...(Object.keys(examples).length ? { examples } : {}),
};
}
/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */
/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */
export interface MasterLogicBrief {
file: string;
구분: string;
상세구분: string;
키: string;
원문번호: string;
이름: string;
결과단위: string;
blocked?: boolean;
reasons?: string[];
}
/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */
export function filterLogics(
items: MasterLogicBrief[],
q: { text: string; book?: string; chapter?: string },
): MasterLogicBrief[] {
const words = q.text.split(/\s+/).map(squash).filter(Boolean);
return items.filter((i) => {
if (q.book && i.구분 !== q.book) return false;
if (q.chapter && i.상세구분 !== q.chapter) return false;
const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`);
return words.every((w) => hay.includes(w));
});
}
/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */
export function logicBooks(items: MasterLogicBrief[]): string[] {
return [...new Set(items.map((i) => i.구분).filter(Boolean))];
}
export function logicChapters(items: MasterLogicBrief[], book: string): string[] {
return [
...new Set(
items
.filter((i) => !book || i.구분 === book)
.map((i) => i.상세구분)
.filter(Boolean),
),
];
}
/** 옛 로직을 흐름도로 바꿔 읽은 것은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */
export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const;
/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */
export function masterRef(
key: string,
flow: CfLogic,
converted: boolean,
resultUnit: string,
): CfLogicRef {
const ref = logicRefOf(key, flow);
return converted
? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) }
: ref;
}
/** 옛 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */
export function rowRef(
key: string,
name: string,
resultUnit: string,
inputs: { 이름: string; 단위?: string }[],
auto: Record<string, unknown>,
): CfLogicRef {
const examples: Record<string, number | string> = {};
for (const i of inputs) {
const v = auto[i.이름];
if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v;
}
return {
key,
name,
outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })),
designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })),
...(Object.keys(examples).length ? { examples } : {}),
};
}
/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */
export async function wouldCycle(
selfKey: string | null,
startKey: string,
load: (key: string) => Promise<CfLogic | null>,
depth = 8,
): Promise<boolean> {
if (!selfKey) return false;
const seen = new Set<string>();
const walk = async (key: string, left: number): Promise<boolean> => {
if (key === selfKey) return true;
if (seen.has(key) || left <= 0) return false;
seen.add(key);
const lg = await load(key).catch(() => null);
for (const b of lg?.boxes ?? []) {
const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined;
if (next && (await walk(next, left - 1))) return true;
}
return false;
};
return walk(startKey, depth);
}
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */
export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic {
return {
@@ -473,3 +593,36 @@ export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
? lg
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
}
/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(옛 로직 LB:{직종} · EQ:{기계} 꼴) ── */
/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */
export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"];
export type GroupRuleField = "이름" | "키";
/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */
export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean {
const v = squash(value);
if (!v) return false;
const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]];
return targets.some((x) => squash(x) === v);
}
/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */
export function groupRuleElement(
name: string,
field: GroupRuleField,
from: CfMaterialRule["from"],
): CfElementRef {
const rule = { field, op: "같음", from } as unknown as CfMaterialRule;
return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule };
}
/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null {
if (!el.name) return "자리 이름을 적어 주세요";
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
return null;
}
@@ -1,42 +1,92 @@
/* =============================================================================
* M02_DF_InputPanel_Logic.ts
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
import type { CfLogic } from "./M02_DF_Model";
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchAuto,
fetchLogic,
fetchLogics,
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
import {
filterLogics,
logicBooks,
logicChapters,
logicRefOf,
masterRef,
rowRef,
pageRange,
setKind,
setLogicInput,
wouldCycle,
type MasterLogicBrief,
} from "./M02_DF_InputPanel_Build";
const TEXT = {
Help: [
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use a saved logic or sample as an input",
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use any master logic or a logic of mine as an input",
],
Search: ["로직 이름으로 찾기", "Search by name"],
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
Book: ["원문", "Source"],
Chapter: ["장", "Chapter"],
All: ["전체", "All"],
Loading: ["찾는 중…", "Searching…"],
None: ["찾는 로직 없음", "No logic found"],
Sample: ["견본", "Sample"],
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Mine: ["내가 만든 것", "Mine"],
Blocked: ["읽을 수 없음", "Unreadable"],
Chosen: ["고른 로직", "Chosen logic"],
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
Converted: [
"옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜",
"Old logic read as a flow — the file is unchanged",
],
Notes: ["못 옮긴 곳", "Not carried over"],
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
Designs: [
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)",
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
"Pills it takes — its design inputs",
],
NoOut: ["출력 상자가 없음", "No output box"],
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
Change: ["다른 로직 고르기", "Pick another"],
Remove: ["빼기", "Remove"],
Self: ["이 로직 자신은 못 씀", "Can't use itself"],
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
Cycle: [
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
"That would loop — a logic that uses this one can't be picked",
],
NoFlow: [
"이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음",
"Can't read this logic's flow yet",
],
Failed: ["불러오지 못함", "Could not load"],
Wait: ["값 셈 중…", "Calculating…"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
const plain = (e: unknown): string => {
try {
@@ -46,6 +96,40 @@ const plain = (e: unknown): string => {
}
};
const PAGE = 12;
const MINE = "흐름도";
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
let cache: Promise<MasterLogicBrief[]> | null = null;
const loadAll = (): Promise<MasterLogicBrief[]> =>
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
.then(([master, mine]) => [
...mine
.filter((i) => !i.sample)
.map((i): MasterLogicBrief => ({
file: MINE,
구분: MINE,
상세구분: t("Mine"),
키: i.key,
원문번호: "",
이름: i.name,
결과단위: i.resultUnit,
})),
...master,
])
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
async function flowOf(
item: Pick<MasterLogicBrief, "file" | "키">,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
return cfLogicFlow(item.file, item.키);
}
/** 다른 로직 입력 판 */
export function renderLogicInput(
host: HTMLElement,
@@ -55,32 +139,86 @@ export function renderLogicInput(
): void {
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
if (ref) return void showChosen(host, lg, boxId, ref, on);
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
let all: CfListItem[] = [];
const draw = (): void => {
const word = q.input.value.trim().toLowerCase();
const rows = all.filter(
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
const all = { value: "", text: t("All") };
let book = "";
let chapter = "";
let page = 1;
let items: MasterLogicBrief[] = [];
const bookBox = createSelectField({
label: t("Book"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
});
const chapterBox = createSelectField({
label: t("Chapter"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((chapter = v), (page = 1), draw()),
});
const fillChapters = (): void =>
chapterBox.setOptions(
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
chapter,
);
const list = el("div", { className: "m02-dfip__list" });
const info = el("span", { className: "m02-dfip__muted" });
const prev = createButton({
label: t("Prev"),
variant: "ghost",
size: "sm",
onClick: () => ((page -= 1), draw()),
});
const next = createButton({
label: t("Next"),
variant: "ghost",
size: "sm",
onClick: () => ((page += 1), draw()),
});
const draw = (): void => {
const rows = filterLogics(
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
{ text: q.input.value, book, chapter },
);
const range = pageRange(rows.length, page, PAGE);
page = Math.min(page, range.pages);
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
info.textContent = t("Total", {
n: rows.length,
a: range.from,
b: range.to,
p: page,
m: range.pages,
});
prev.disabled = page <= 1;
next.disabled = page >= range.pages;
list.replaceChildren(
...(rows.length
? rows.map((i) => {
...(shown.length
? shown.map((i) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
attrs: {
type: "button",
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
},
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: i.name }),
el("strong", { text: i.이름 }),
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
el("span", {
className: "m02-dfip__tag",
text: i.sample ? t("Sample") : t("Mine"),
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
}),
],
}),
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
],
});
b.addEventListener("click", () => void pick(i));
@@ -89,56 +227,119 @@ export function renderLogicInput(
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
);
};
const pick = async (i: CfListItem): Promise<void> => {
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
const it = items.find((x) => x.키 === key);
return it ? (await flowOf(it)).flow : null;
};
const pick = async (i: MasterLogicBrief): Promise<void> => {
try {
const other = await cfGet(i.key);
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
if (i.키 === lg.key) return void showToast(t("Self"), "error");
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
const got = await flowOf(i);
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
// 옛 로직을 흐름도로 바꾼 것이 아직 비어 있으면 옛 줄의 입력 · 자동값에서 바로 읽음
const empty = got.converted && got.flow.boxes.length === 0;
const row = empty ? await fetchLogic(i.키).catch(() => null) : null;
const picked = row
? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키))
: got.converted
? masterRef(i.키, got.flow, true, i.결과단위)
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
const done = setLogicInput(lg, boxId, picked);
// 기본 이름이면 고른 로직 이름으로
on.change({
...next,
boxes: next.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
...done,
boxes: done.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
),
});
} catch (e) {
showToast(plain(e), "error");
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
}
};
q.input.addEventListener("input", draw);
let timer = 0;
q.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => ((page = 1), draw()), 200);
});
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
void cfList().then(
(items) => ((all = items), draw()),
host.replaceChildren(
el("p", { className: "m02-dfip__muted", text: t("Help") }),
el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }),
info,
list,
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
);
void loadAll().then(
(rows) => {
items = rows;
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
fillChapters();
draw();
},
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
);
}
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
ref: CfLogicRef,
on: { change(lg: CfLogic): void },
): void {
if (!ref) return;
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
const read = readNotes.get(ref.key);
const values = new Map<string, HTMLElement>();
const errors = el("p", { className: "m02-dfip__muted is-bad" });
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: p.name }),
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
el("span", { className: "m02-dfip__name", text: name }),
...(valueNode
? [valueNode]
: extra
? [el("span", { className: "m02-dfip__muted", text: extra })]
: []),
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
],
});
host.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
...(read?.converted
? [
el("p", { className: "m02-dfip__muted", text: t("Converted") }),
...(read.notes.length
? [
el("details", {
children: [
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
...read.notes.map((n) => el("p", { text: n })),
],
}),
]
: []),
]
: []),
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
...(ref.outputs.length
? ref.outputs.map(pillLine)
? ref.outputs.map((o) => {
const v = el("strong", { className: "m02-dfip__value", text: "—" });
values.set(o.name, v);
return line(o.name, o.unit, v);
})
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
errors,
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
...(ref.designs.length
? ref.designs.map(pillLine)
? ref.designs.map((d) =>
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
)
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
el("div", {
className: "m02-dfip__chips",
@@ -158,4 +359,17 @@ function showChosen(
],
}),
);
// 값 — 서버 계산 길(받는 알약 예시값으로)
errors.textContent = t("Wait");
void cfLogicValue(ref.key, ref.examples ?? {}).then(
(r) => {
for (const [name, node] of values) {
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
if (typeof v === "number") node.textContent = fmt(v);
else if (typeof v === "string") node.textContent = v;
}
errors.textContent = r.errors.join(" · ");
},
(e) => (errors.textContent = plain(e)),
);
}
@@ -0,0 +1,138 @@
/* =============================================================================
* M02_DF_InputPanel_Rule.ts
* 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음
* (옛 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래
* 선택 자리(choice)는 재료만 — 이 판에 없음
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
import {
catchNote,
checkGroupRule,
groupRuleElement,
variablePills,
type GroupRuleField,
} from "./M02_DF_InputPanel_Build";
const TEXT = {
Help: [
"변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄",
"Catches the row whose name (or key) equals a variable pill's value",
],
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
Field: ["같아야 하는 칸", "Column"],
FieldName: ["이름", "Name"],
FieldKey: ["키(원문번호)", "Key (source no.)"],
Pill: ["변수 알약", "Variable pill"],
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
Preview: ["지금 잡히는 줄", "Rows caught now"],
Put: ["넣기", "Add"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */
export type RulePreview = (
group: CfGroup,
field: GroupRuleField,
value: unknown,
) => Promise<{ found: number; names: string[]; unit: string }>;
/** 조건 찾기 요소 추가 판 */
export function renderGroupRule(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
preview: RulePreview,
on: { change(lg: CfLogic): void },
): void {
const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? [];
const pills = variablePills(lg);
const name = createInputField({
type: "text",
label: t("SlotName"),
compact: true,
placeholder: group === "인력" ? "예: 직종" : "예: 기종",
});
const field = createSelectField({
label: t("Field"),
options: [
{ value: "이름", text: t("FieldName") },
{ value: "키", text: t("FieldKey") },
],
value: "이름",
compact: true,
onChange: () => void refresh(),
});
const pill = createSelectField({
label: t("Pill"),
options: pills.map((p, i) => ({
value: String(i),
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
})),
value: "0",
compact: true,
onChange: () => void refresh(),
});
const note = el("p", { className: "m02-dfip__note" });
const build = (): CfElementRef =>
groupRuleElement(
name.input.value,
field.select.value as GroupRuleField,
pills[Number(pill.select.value)]?.port ?? { box: "", name: "" },
);
let seq = 0;
const refresh = async (): Promise<void> => {
const mine = ++seq;
const value = pills[Number(pill.select.value)]?.example;
note.textContent = `${t("Preview")}: …`;
try {
const p = await preview(group, field.select.value as GroupRuleField, value);
if (mine !== seq) return;
const n = catchNote(p.found, p.names[0], "줄");
note.textContent = `${t("Preview")}: ${n.text}`;
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
} catch {
if (mine === seq) note.textContent = t("Failed");
}
};
const add = createButton({
label: t("Put"),
size: "sm",
onClick: () => {
const cand = build();
const why = checkGroupRule(cand, els);
name.setError(why ?? undefined);
if (why) return void showToast(why, "error");
// 잡히는 줄이 하나면 그 단위를 알약 단위로
void preview(
group,
field.select.value as GroupRuleField,
pills[Number(pill.select.value)]?.example,
).then(
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
() => on.change(addEl(lg, cand)),
);
},
});
host.replaceChildren(
el("p", { className: "m02-dfip__muted", text: t("Help") }),
name.root,
...(pills.length
? [field.root, pill.root, note, add]
: [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]),
);
if (pills.length) void refresh();
}
+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)})` }),
],
}),
@@ -0,0 +1,151 @@
/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */
.m02-dfp {
position: relative;
display: flex;
width: 100%;
min-width: 0;
height: 100%;
border-left: 1px solid var(--color-border);
background: var(--color-canvas);
}
.m02-dfp__open {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
}
.m02-dfp__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-dfp__tools {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */
.m02-dfp .m02-dfdg__view {
height: max(320px, 45vh);
}
.m02-dfp__lock {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.m02-dfp__cost > summary {
cursor: pointer;
font-weight: 600;
padding: var(--spacing-4) 0;
}
/* 왼쪽 가장자리 끌기 */
.m02-dfp__grip {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
z-index: 2;
width: 8px;
cursor: col-resize;
touch-action: none;
}
.m02-dfp__grip:hover,
.m02-dfp__grip:focus-visible,
.m02-dfp.is-resizing .m02-dfp__grip {
background: color-mix(in srgb, var(--color-accent) 35%, transparent);
outline: none;
}
.m02-dfp.is-resizing {
user-select: none;
}
/* 접기 = 얇은 띠 */
.m02-dfp__strip {
width: 100%;
padding: var(--spacing-12) 0;
border: 0;
background: var(--color-surface);
color: var(--color-text-secondary);
font: inherit;
writing-mode: vertical-rl;
cursor: pointer;
}
.m02-dfp__strip:hover,
.m02-dfp__strip:focus-visible {
color: var(--color-accent);
}
.m02-dfp__strip[hidden],
.m02-dfp__open[hidden],
.m02-dfp__grip[hidden] {
display: none;
}
/* 크게 보기 — 화면 전체 */
.m02-dfp-full {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
overflow: auto;
background: var(--color-canvas);
outline: none;
}
.m02-dfp-full__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp-full__head strong {
flex: 1;
font-size: var(--text-body);
}
.m02-dfp-full__diagram .m02-dfdg__view {
height: max(420px, 60vh);
}
.m02-dfp-full__lower {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-dfp-full__lower h3 {
margin: 0 0 var(--spacing-8);
font-size: var(--text-body);
}
@media (max-width: 900px) {
.m02-dfp-full__lower {
grid-template-columns: minmax(0, 1fr);
}
}
+272
View File
@@ -0,0 +1,272 @@
/* =============================================================================
* M02_DF_Panel.ts
* 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand }
* 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) →
* 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange.
* 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage ·
* 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로).
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfLogic } from "./M02_DF_Model";
import { createPaper } from "./M02_DF_Paper";
import "./M02_DF_Panel.css";
const TEXT = {
Big: ["크게 보기", "Full screen"],
Fold: ["접기", "Collapse"],
Strip: ["흐름도 ▸", "Flow ▸"],
StripTitle: ["흐름도 펴기", "Open the flow panel"],
Close: ["닫기", "Close"],
Detail: ["상세 내용", "Details"],
Cost: ["원가 · 검증", "Cost · check"],
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
const tf = (key: keyof typeof TEXT): string =>
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
export interface FlowPanelOpts {
logic: CfLogic;
readOnly?: boolean;
title: string;
/** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 */
logics?(key: string): Promise<CfLogic | null>;
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
onResize?(px: number): void;
}
export interface FlowPanel {
/** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */
set(lg: CfLogic): void;
destroy(): void;
/** 크게 보기 열기 */
expand(): void;
}
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
export const PANEL_MIN = 320;
export const STRIP_WIDTH = 40;
/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */
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";
/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */
export function dragWidth(start: number, startX: number, x: number, viewport: number): number {
const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85));
return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x))));
}
/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */
export function storedWidth(raw: string | null): number | null {
const n = Number(raw);
return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null;
}
const readStore = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const writeStore = (key: string, value: string): void => {
try {
localStorage.setItem(key, value);
} catch {
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */
}
};
/* ── 패널 ───────────────────────────────────────────────────────────── */
export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel {
/** 크게 보기 화면 — 열려 있을 때만 */
let full: HTMLElement | null = null;
const paper = createPaper({
logic: opts.logic,
readOnly: opts.readOnly,
onChange: opts.onChange,
logics: opts.logics,
// 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 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));
let folded = readStore(FOLD_KEY) === "1";
const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth));
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
createButton({ label, size: "sm", variant, onClick });
/* 펼친 패널 */
const costBox = el("details", {
className: "m02-dfp__cost",
attrs: { open: "" },
children: [el("summary", { text: tf("Cost") })],
});
const tools = el("div", { className: "m02-dfp__tools", children: [undo] });
const open = el("div", {
className: "m02-dfp__open",
children: [
el("header", {
className: "m02-dfp__head",
children: [
el("strong", { className: "m02-dfp__title", text: opts.title }),
sm(tf("Big"), () => expand()),
sm(tf("Fold"), () => setFolded(true)),
],
}),
tools,
note,
diagram,
detail,
costBox,
],
});
costBox.append(summary);
/* 접힌 띠 */
const strip = el("button", {
className: "m02-dfp__strip",
text: tf("Strip"),
attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") },
});
strip.addEventListener("click", () => setFolded(false));
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
const grip = el("div", {
className: "m02-dfp__grip",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": tf("Grip"),
tabindex: "0",
},
});
const setWidth = (px: number, save: boolean): void => {
width = px;
if (save) writeStore(WIDTH_KEY, String(px));
tell();
};
grip.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const start = root.getBoundingClientRect().width;
const startX = ev.clientX;
root.classList.add("is-resizing");
const move = (m: PointerEvent): void =>
setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false);
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
root.classList.remove("is-resizing");
setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true);
paper.refit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
grip.addEventListener("keydown", (ev) => {
const step = ev.shiftKey ? 80 : 20;
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
if (!d) return;
ev.preventDefault();
const now = root.getBoundingClientRect().width;
setWidth(dragWidth(now, 0, -d, window.innerWidth), true);
});
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
root.addEventListener("keydown", (ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
const setFolded = (on: boolean): void => {
folded = on;
writeStore(FOLD_KEY, on ? "1" : "0");
root.classList.toggle("is-folded", on);
open.hidden = on;
strip.hidden = !on;
grip.hidden = on;
tell();
if (!on) paper.refit();
};
/* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */
const expand = (): void => {
if (full) return;
const close = sm(tf("Close"), () => shrink());
full = el("div", {
className: "m02-dfp-full",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" },
children: [
el("header", {
className: "m02-dfp-full__head",
children: [el("strong", { text: opts.title }), undo, close],
}),
note,
el("div", { className: "m02-dfp-full__diagram", children: [diagram] }),
el("div", {
className: "m02-dfp-full__lower",
children: [
el("section", { children: [el("h3", { text: tf("Detail") }), detail] }),
el("section", { children: [el("h3", { text: tf("Cost") }), summary] }),
],
}),
],
});
full.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
document.body.append(full);
full.focus();
paper.refit();
};
const shrink = (): void => {
if (!full) return;
full.remove();
full = null;
tools.append(undo);
costBox.append(summary);
// 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움
open.replaceChildren(
open.firstElementChild as HTMLElement,
tools,
note,
diagram,
detail,
costBox,
);
paper.refit();
};
host.replaceChildren(root);
setFolded(folded);
return {
set: (lg) => paper.set(lg),
destroy: () => {
shrink();
root.remove();
},
expand,
};
}
+347
View File
@@ -0,0 +1,347 @@
/* =============================================================================
* M02_DF_Paper.ts
* 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소
* Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀.
* 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로).
* readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름).
* 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import * as api from "./M02_DF_Api";
import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram";
import * as ev from "./M02_DF_Eval";
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import { renderFuncPanel } from "./M02_DF_FuncPanel";
import { renderInputPanel } from "./M02_DF_InputPanel";
import { renderLinkPanel } from "./M02_DF_LinkPanel";
import * as model from "./M02_DF_Model";
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
import { renderOutputPanel } from "./M02_DF_OutputPanel";
import { renderPillPanel } from "./M02_DF_PillPanel";
import { renderSummary } from "./M02_DF_Summary";
import type { CfSel } from "./M02_DF_Verify";
import {
detailFrame,
drawHead,
drawWarnings,
fetchValues,
loadLogics,
queriesOf,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Entry.css";
const TEXT = {
Undo: ["실행 취소", "Undo"],
Color: ["상자 색", "Box colour"],
BoxName: ["상자 이름", "Box name"],
NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"],
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"],
} as const;
const tp = (key: keyof typeof TEXT, v = ""): string =>
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v);
export interface PaperOpts {
logic: CfLogic;
readOnly?: boolean;
/** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
/** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */
fitAllMin?(): number;
}
export interface Paper {
/** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */
parts: {
diagram: HTMLElement;
detail: HTMLElement;
summary: HTMLElement;
note: HTMLElement;
undo: HTMLButtonElement;
};
get(): CfLogic;
/** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */
set(lg: CfLogic): void;
undo(): void;
/** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */
refit(): void;
redraw(): void;
}
export function createPaper(opts: PaperOpts): Paper {
const readOnly = !!opts.readOnly;
let lg = opts.logic;
let stack: CfLogic[] = [];
let pick: DfPick = null;
let values: CfValues = {};
let valueSig = "";
let logics: NonNullable<CfEvalOptions["logics"]> = {};
let logicSig = "";
let logicNotes: string[] = [];
let notes: string[] = [];
let broken: CfBreak[] = [];
let result: CfEvalResult;
let fitKey = 0;
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
const diagramHost = el("div", { className: "m02-dfp__diagram" });
const summaryHost = el("div", { className: "m02-dfp__summary" });
const colorBox = el("input", {
className: "m02-df__color-input",
attrs: { type: "color", "aria-label": tp("Color") },
});
colorBox.addEventListener("change", () => {
const id = pickedBox();
if (id) edit(model.setColor(lg, id, colorBox.value));
});
const colorRow = el("label", {
className: "m02-df__color",
children: [el("span", { text: tp("Color") }), colorBox],
});
const boxName = createInputField({ label: tp("BoxName"), compact: true });
boxName.input.addEventListener("change", () => {
const box = lg.boxes.find((b) => b.id === pickedBox());
if (!box) return;
const next = boxName.input.value.trim();
const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next);
if (taken) note.textContent = tp("NameTaken", next);
if (!next || taken || next === box.name) return void (boxName.input.value = box.name);
edit(model.updateBox(lg, box.id, { name: next }));
});
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만
const funcHost = el("div");
const outHost = el("div");
const inHost = el("div");
const linkHost = el("div");
const pillHost = el("div");
const detail = detailFrame();
detail.settings.append(boxName.root, colorRow);
// 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨)
const lock = el("fieldset", {
className: "m02-dfp__lock",
children: [inHost, funcHost, outHost, linkHost, pillHost],
});
lock.disabled = readOnly;
detail.body.append(lock);
if (readOnly) detail.root.prepend(el("p", { className: "m02-df__muted", text: tp("ReadOnly") }));
const evalNow = (): CfEvalResult => {
try {
return ev.evalLogic(lg, values, { logics });
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] };
}
};
/** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */
const edit = (next: CfLogic): void => {
if (readOnly || next === lg) return;
stack = [...stack, lg];
lg = next;
opts.onChange(lg);
redraw();
};
/** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */
const editExample = (next: CfLogic): void => {
if (!readOnly) return edit(next);
lg = next;
redraw();
};
const editFromDiagram = (next: CfLogic): void => {
edit(next);
setTimeout(redraw);
};
const refreshValues = (): void => {
const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null;
const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(api.cfValues, lg, pill, model.elementPort).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : [];
redraw();
},
(e: unknown) => {
notes = [tp("Values", api.cfPlain(e))];
redraw();
},
);
};
const refreshLogics = (): void => {
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
if (sig === logicSig) return;
logicSig = sig;
const get =
opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null));
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : [];
redraw();
});
};
const choose = (sel: CfSel): void => {
const box = lg.boxes.find((b) => b.id === sel.box);
pick = sel.link
? { kind: "link", id: sel.link }
: box && sel.port
? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) }
: box
? { kind: "box", id: box.id }
: null;
redraw();
};
/** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */
const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => {
try {
if (pick?.kind === "pill") {
const p = { box: pick.box, name: pick.port };
return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) };
}
const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id);
if (link)
return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) };
} catch {
/* 따라갈 수 없으면 칠하지 않음 */
}
return null;
};
const diagramOn = {
select: (id: string | null, pill?: DfPillSel): void => {
pick = pill
? { kind: "pill", ...pill }
: !id
? null
: lg.links.some((l) => l.id === id)
? { kind: "link", id }
: { kind: "box", id };
setTimeout(redraw);
},
move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
const got = model.link(lg, from, to);
if (got.why) return got.why;
editFromDiagram(got.lg);
return null;
},
unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)),
add: (kind: CfKind, x: number, y: number): void => {
const got = model.addBox(lg, kind, x, y);
pick = { kind: "box", id: got.box.id };
editFromDiagram(got.lg);
},
remove: (id: string): void => {
if (pickedBox() === id) pick = null;
editFromDiagram(model.removeBox(lg, id));
},
};
const drawPanel = (): void => {
const boxId = pickedBox();
if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null;
const box = lg.boxes.find((b) => b.id === pickedBox());
drawHead(detail, lg, pick);
(detail.settings.parentElement as HTMLElement).hidden = !box || readOnly;
colorRow.hidden = !box;
boxName.root.hidden = !box || box.kind === "output";
if (box && document.activeElement !== boxName.input) boxName.input.value = box.name;
linkHost.hidden = pick?.kind !== "link";
pillHost.hidden = pick?.kind !== "pill";
if (pick?.kind === "link")
renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
if (pick?.kind === "pill")
renderPillPanel(
pillHost,
lg,
pick.box,
pick.port,
{ pick: (id) => choose({ box: id }) },
{ values, logics },
);
drawWarnings(detail, pick, broken);
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
funcHost.hidden = box?.kind !== "function";
outHost.hidden = box?.kind !== "output";
inHost.hidden = box?.kind !== "input";
if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit });
if (box?.kind === "function") renderFuncPanel(funcHost, lg, box.id, { change: edit });
if (box?.kind === "output")
renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
};
const redraw = (): void => {
result = evalNow();
refreshValues();
refreshLogics();
const linkId = pick?.kind === "link" ? pick.id : "";
if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null;
try {
broken = ev.breaks(lg, values, { logics });
} catch {
broken = result.breaks ?? [];
}
undo.disabled = readOnly || !stack.length;
renderDiagram(diagramHost, lg, diagramOn, {
values: result.ports,
selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null,
selectedPill:
pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null,
trace: traceNow(),
breaks: broken,
readOnly,
fitKey,
fitMin: opts.fitMin?.(),
fitAllMin: opts.fitAllMin?.(),
});
drawPanel();
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back || readOnly) return;
stack = stack.slice(0, -1);
lg = back;
opts.onChange(lg);
redraw();
};
const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo });
redraw();
return {
parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo },
get: () => lg,
set: (next) => {
lg = next;
stack = [];
pick = null;
fitKey += 1;
redraw();
},
undo: doUndo,
refit: () => {
fitKey += 1;
redraw();
},
redraw,
};
}
@@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string {
maximumFractionDigits: Math.max(0, r.places),
});
}
/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */
let totalNow: CfRound = { places: 0, method: "버림" };
void defaultTotalRound().then((r) => (totalNow = r));
export const totalRoundNow = (): CfRound => totalNow;
+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)}
# ── 길 ────────────────────────────────────────────────────────────────
@@ -1,78 +0,0 @@
/* =============================================================================
* M02_DF_Sample.ts
* 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬)
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비
* ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비
* ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비
* ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비
* ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
* 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음.
* 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함
* `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { CfLogic } from "./M02_DF_Model";
const FILES = import.meta.glob<CfLogic>("./_sample/*.json", { eager: true, import: "default" });
/** 견본마다 한 줄 설명 — [한, 영] */
const NOTE: Record<string, readonly [string, string]> = {
masonry: [
"변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비",
"Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost",
],
ground: [
"지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비",
"Soil goes to the excavator, rock to the breaker — two gate lines → expense",
],
hume: [
"관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비",
"Pipe size picks the hume pipe item (spec contains size) → count × price → material cost",
],
linked: [
"작업량 로직의 출력을 받아 장비 시간 → 경비",
"Takes the output of a productivity logic → machine hours → expense",
],
value: [
"q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)",
"q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)",
],
};
/** 보여 줄 차례 */
const ORDER = ["masonry", "ground", "hume", "linked", "value"];
export interface CfSampleItem {
/** 파일 이름(확장자 뺌) */
id: string;
name: string;
resultUnit: string;
/** 쉬운 말 한 줄 */
note: string;
}
const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, "");
const byId = (): Map<string, CfLogic> =>
new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg]));
/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */
export function sampleList(): CfSampleItem[] {
const all = byId();
const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length);
return [...all.entries()]
.sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name))
.map(([id, lg]) => ({
id,
name: lg.name,
resultUnit: lg.resultUnit,
note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "",
}));
}
/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */
export function sampleLoad(id: string): CfLogic | null {
const lg = byId().get(id);
return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null;
}
+119 -12
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import re
from contextvars import ContextVar
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
@@ -21,6 +22,36 @@ RANGE_LOW, RANGE_HIGH = "·아래", "·위"
FIELDS = (*cm.mf.COST_ITEMS, "계")
# 단가 자리 — `mf.run` 을 부른 쪽이 넘긴 Master(프로젝트 사본이면 그 단가) · 없으면 마스터
# ponytail: 단가 · 값 · 표만 이 자리 · 찾기 목록(재료 조건 찾기 · 그룹 찾기 · 기계 짝)은 마스터에서
SOURCE: ContextVar = ContextVar("df_price_source", default=None)
_DRAFT = "GX999998" # 단가 자리에 잠깐 얹는 견본 로직 키
def _whole():
return SOURCE.get() or store.loaded()[1]
def _calc(inputs: dict, row: dict) -> dict:
"""`Store.calc("", inputs, row)` 와 같은 꼴 — 단가 자리가 넘겨졌으면 그 Master 로 셈."""
whole = SOURCE.get()
if whole is None:
return store.calc("", inputs, row, None)
slot = whole.index.setdefault(_DRAFT[:2], {})
slot[_DRAFT] = {**store._dec(row), "키": _DRAFT}
try:
given = cm.mf.fill_empty(slot[_DRAFT], {k: store._dec(v) for k, v in inputs.items()})
got = cm.mf.run(whole, _DRAFT, given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
finally:
slot.pop(_DRAFT, None)
if "결과" in got:
return {"ok": True, "result": got["결과"], "middle": got["중간"]}
sums = {k: got[k] for k in cm.mf.BUNDLE}
return {"ok": True, "lines": got["줄"], "sums": sums, "middle": got["중간"]}
# ── 입력 값 ────────────────────────────────────────────────────────────
def _row(line: dict, names) -> dict:
"""단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직."""
@@ -65,7 +96,7 @@ def _pick(key: Any) -> dict | None:
def _material(cond: dict, unit: str) -> dict:
"""검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산)."""
line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"}
got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None)
got = _calc({}, _row({**line, "단위": unit, "비목": "재료비"}, ()))
if not got["ok"]:
return got
one = got["lines"][0]
@@ -85,7 +116,7 @@ def _material(cond: dict, unit: str) -> dict:
def _brief(key: str, want: str) -> tuple[dict | None, str | None]:
"""마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키."""
try:
row = store.loaded()[1].get(key)
row = _whole().get(key)
except cm.mf.FormulaError as e:
return None, str(e)
group = store.mk.GROUP_OF.get(store.mk.id_of(key))
@@ -102,7 +133,7 @@ def _price(group: str, key: str, unit: str = "") -> dict:
kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group]
line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"}
line = {**line, "단위": unit or row.get("단위") or "", "비목": cost}
got = store.calc("", {}, _row(line, ()), None)
got = _calc({}, _row(line, ()))
if not got["ok"]:
return got
one = got["lines"][0]
@@ -135,6 +166,12 @@ def _plain_value(group: str, key: str, field: str | None) -> dict:
def _table(key: str, at: Any = None) -> dict:
"""소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 ·
그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나."""
if SOURCE.get() is not None: # 단가 자리(프로젝트 사본)의 표
try:
table = SOURCE.get().get(key)
except cm.mf.FormulaError:
table = None
return _table_of(table, key, at)
files = store.loaded()[0]
table = next(
(
@@ -146,6 +183,10 @@ def _table(key: str, at: Any = None) -> dict:
),
None,
)
return _table_of(table, key, at)
def _table_of(table: dict | None, key: str, at: Any) -> dict:
if table is None:
return {"ok": False, "reason": f"없는 표 「{key}」"}
conds, lines = table.get("조건") or {}, table.get("줄") or []
@@ -158,11 +199,11 @@ def _table(key: str, at: Any = None) -> dict:
if not isinstance(at, int) or not 0 <= at < len(lines):
return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"}
lines = [lines[at]]
rows = []
rows, worth = [], set(table.get("값칸") or {})
for r in lines:
line = []
for n in names:
v = r.get(n)
v = _arith(r.get(n)) if n in worth else r.get(n)
if n in split:
line += list(v) if isinstance(v, list) else [v, v]
else:
@@ -171,6 +212,16 @@ def _table(key: str, at: Any = None) -> dict:
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
def _arith(v: Any) -> Any:
"""값 칸 수 식 글(「(0.05)」 「1/1.30」) — 옛 셈처럼 수로(사칙연산 · 괄호만 · 옛 엔진 파서)."""
if not isinstance(v, str) or not cm.mf._ARITH.fullmatch(v):
return v
try:
return cm.mf.evaluate(cm.mf.parse(v), {}, None, 0)
except (cm.mf.FormulaError, ArithmeticError):
return v
def _user(uid: str, local: list[dict]) -> dict:
"""사용자 요소 값 — local = 다이어그램 안 것 + 공용 목록(부르는 쪽이 모음)."""
one = next((r for r in local if r.get("id") == uid), None)
@@ -226,7 +277,7 @@ def _machine_hourly(key: str, inputs: dict) -> dict:
args = "".join(f", {n}='{v}'" for n, v in given.items())
ref = f"로직({logic['키']}, 기계='{code}'{args})"
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", {}, _row(line, ()), None), "계")
got = _money(_calc({}, _row(line, ())), "계")
return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got
@@ -259,7 +310,7 @@ def one_value(item: dict, local: list[dict] | None = None) -> dict:
elif group == "기계" and str(ref or "").startswith("로직("):
inputs = item.get("inputs") or {}
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", inputs, _row(line, inputs), None), field)
got = _money(_calc(inputs, _row(line, inputs)), field)
elif group == "기계" and field == HOURLY:
got = _machine_hourly(str(ref or ""), item.get("inputs") or {})
elif group in ("기계", "유가전력", "환율", "요율"):
@@ -336,7 +387,7 @@ def materials(q: str, sub: str, detail: str, page: int, size: int) -> dict:
return {"total": len(hits), "page": page, "size": size, "items": [_brief_row(r) for r in part]}
FIELDS_RULE, OPS_RULE = ("규격", "이름"), ("포함", "같음") # Model CfMaterialRule
FIELDS_RULE, OPS_RULE = ("규격", "이름", "키"), ("포함", "같음") # Model CfMaterialRule
def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict:
@@ -368,10 +419,13 @@ def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict:
want = _squash(value)
sub, detail = str(scope.get("구분") or ""), str(scope.get("상세구분") or "")
rows = _found(sub, detail, scope.get("찾는말") or "")
if op == "같음":
hits = [r for r in rows if _squash(r.get(field)) == want]
else:
hits = [r for r in rows if want in _squash(r.get(field))]
numbers = _numbers() if field == "키" else {} # 키 = 자재 키 또는 품번(원문번호)
def pool(r: dict) -> list[str]:
got = [r.get("키"), numbers.get(r.get("키"))] if field == "키" else [r.get(field)]
return [_squash(x) for x in got if x]
hits = [r for r in rows if any(want == x if op == "같음" else want in x for x in pool(r))]
if not hits:
where = " · ".join(x for x in (sub, detail) if x) or "전체"
return {"ok": False, "reason": f"맞는 재료 없음 — {where} 에서 {field}에 「{value}」 {op}"}
@@ -386,6 +440,59 @@ def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict:
return {**got, "ref": hits[0].get("키"), "name": hits[0].get("이름")}
GROUP_RULE = (
"인력",
"기계",
"유가전력",
) # 조건 찾기를 재료 밖에서도(옛 LB:{직종} · EQ:{기계} · MO:…)
def _group_rows(group: str) -> list[dict]:
"""그룹 줄 — 인력 · 기계 = 그 파일 · 유가전력 = 재료 안 MO 키."""
files = store.loaded()[0]
want = "재료" if group == "유가전력" else group
return [
r
for d in files.values()
if d.get("그룹") == want
for r in d.get(store.items_key(d)) or []
if group != "유가전력" or str(r.get("키")).startswith("MO")
]
def group_find(group: str, slot: dict, value: Any) -> dict:
"""인력 · 기계 · 유가전력 조건 찾기 — 알약 값이 이름 · 키(원문번호) 와 같음(포함)인 줄 하나.
slot = {field?: 이름 | 키, op?: 같음 | 포함, scope?: {찾는말?}, ref?: 「키#칸」 = 칸 이어 씀}
· field 가 없으면 키(원문번호) 또는 이름이 같은 줄 · 하나면 그 값(`one_value`).
"""
if value in (None, ""):
return {"ok": False, "reason": "찾을 값이 비어 있음 — 이어진 알약을 확인해 주세요"}
field, op, want = slot.get("field"), slot.get("op") or "같음", _squash(value)
words = str((slot.get("scope") or {}).get("찾는말") or "").split()
def hit(r: dict) -> bool:
hay = _squash(f"{r.get('이름')}{r.get('규격') or ''}")
if not all(_squash(w) in hay for w in words):
return False
keys = [_squash(r.get("키")), _squash(r.get("원문번호"))]
names = [_squash(r.get("이름"))]
pool = keys if field == "키" else names if field == "이름" else keys + names
return any(want == x if op == "같음" else want in x for x in pool if x)
hits = [r for r in _group_rows(group) if hit(r)]
if len(hits) != 1:
if not hits:
return {"ok": False, "reason": f"맞는 {group} 없음 — 「{value}」"}
names = ", ".join(f"{r.get('이름')} {r.get('규격') or ''}".strip() for r in hits[:5])
return {"ok": False, "reason": f"맞는 {group} {len(hits)}개 — 좁혀 주세요({names} …)"}
ref = str(hits[0].get("키"))
suffix = str(slot.get("ref") or "").partition(MACHINE_SEP)[2]
got = one_value({"group": group, "ref": f"{ref}{MACHINE_SEP}{suffix}" if suffix else ref,
"unit": slot.get("unit") or ""}) # fmt: skip
return {**got, "ref": ref, "name": hits[0].get("이름")}
# ── 단위 · 금액 표준 표 ────────────────────────────────────────────────
STANDARDS = {"unit": "단위표준.json", "money": "금액단위표준.json"}
@@ -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.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름).
@@ -28,6 +28,11 @@ _OPS = ("<=", ">=", "==", "!=", "<", ">")
_ARITH = re.compile(r"[\d.\s+\-*/()]*\d[\d.\s+\-*/()]*") # 분수·식 값 칸(「1/1.30」)
# 흐름도 로직(줄에 「흐름」) 셈 — 서버(`M01_MasterData_Store_Flow`)가 꽂음 · 없으면 옛 셈만
# 꼴 = (master, 키, 줄, 입력) → `run` 과 같은 꼴(돈 = 줄 · 비목 합 · 계 / 돈 아님 = 결과)
FLOW_RUN = None
class FormulaError(ValueError):
pass
@@ -538,6 +543,8 @@ def _blank_line(item: dict, why: str) -> dict:
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
row = master.logic(ref)
if FLOW_RUN is not None and isinstance(row.get("흐름"), dict):
return FLOW_RUN(master, ref, row, given)
env = _inputs(row, given)
for step in row.get("중간", []):
try:
@@ -0,0 +1,142 @@
# ruff: noqa: E501
"""흐름도 적용(PLAN 36-4 덧) — 옛 엔진 한 곳 갈래 · 인력 · 기계 · 유가전력 조건 찾기.
못박는 것(마스터 사본 폴더)
- `mf.run` 을 바로 부르는 곳(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…))도 흐름 로직이면 흐름 셈
- `/logic/auto` — 흐름 로직은 「설계 때 정할 것」 예시값
- 조건 찾기 요소가 인력 · 기계 · 유가전력에서도 이름 · 키(원문번호) 같음으로 찾음
"""
from __future__ import annotations
import shutil
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
from resources.tester.test_m01_logic_flow_save import KEY, _flow, _get, _save_flow
REAL = store.FOLDER
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(m01_router.router)
return TestClient(app)
def test_옛_엔진도_흐름_로직은_흐름_셈(client: TestClient) -> None:
_save_flow(client, KEY, _flow())
whole = store.cm.mf.Master(store.cm.load(folder=store.FOLDER))
got = store.cm.mf.run(whole, KEY, {"길이": Decimal(4), "폭": Decimal(5)})
assert got["줄"] == [] and got["노무비"] == 20000 and got["계"] == 20000
line = {
"종류": "로직",
"요소": f"로직({KEY}, 길이=4, 폭=5)",
"이름": "흐름 로직",
"단위": "㎡",
"수량": "2",
}
old = store.calc("", {}, dv._row(line, ()), None) # 옛 식이 흐름 로직을 부름
assert old["ok"] and old["sums"]["노무비"] == 40000, old
auto = _get(client, "/api/m01/logic/auto", key=KEY)
assert auto == {"값": {"길이": 2, "폭": 3}}
def _labor_flow(field: str | None, name: str) -> dict:
flow = _flow()
el = {"ref": "", "name": "노임", "spec": "", "unit": "원/인", "mode": "rule",
"rule": {"field": field, "op": "같음", "from": {"box": "who", "name": "직종"}}} # fmt: skip
flow["boxes"] += [
{"id": "who", "kind": "input", "name": "직종", "color": "", "unit": "-", "x": 0, "y": 0,
"input": {"mode": "variable", "elements": [], "variables": [
{"name": "직종", "type": "choice", "unit": "-", "example": name, "choices": [name]}]}},
{"id": "lab", "kind": "input", "name": "노임", "color": "", "unit": "-", "x": 0, "y": 0,
"input": {"mode": "group", "group": "인력", "elements": [el], "variables": []}},
] # fmt: skip
flow["boxes"][2]["output"]["cost"] = "값"
flow["links"][2] = {
"id": "l3",
"from": {"box": "lab", "name": "노임"},
"to": {"box": "out", "name": "값"},
}
return flow
def test_인력_기계_유가전력_조건_찾기(client: TestClient) -> None:
rows = dv._group_rows("인력")
one = next(r for r in rows if sum(x.get("이름") == r.get("이름") for x in rows) == 1)
want = dv.one_value({"group": "인력", "ref": one["키"]})
for field, value in (("이름", one["이름"]), ("키", one["원문번호"]), (None, one["키"])):
got = dc.run(_labor_flow(field, value), {}, lambda k: {}, [], ["t"])
assert got["errors"] == [] and got["cost"]["값들"]["노무비"] == want["value"], (
field,
got["errors"],
)
miss = dc.run(_labor_flow("이름", "없는직종zz"), {}, lambda k: {}, [], ["t"])
assert any("맞는 인력 없음" in e for e in miss["errors"])
machine = dv._group_rows("기계")[0]
got = dv.group_find("기계", {"field": "키"}, machine["원문번호"])
assert got["ref"] == machine["키"]
oils = dv._group_rows("유가전력")
if oils:
got = dv.group_find("유가전력", {"field": "키"}, oils[0]["키"])
assert got["ref"] == oils[0]["키"]
def test_표_값_수_식_글은_수로_재료_키_찾기(client: TestClient) -> None:
assert dv._arith("(0.05)") == Decimal("0.05") and dv._arith("1/1.30") == Decimal(1) / Decimal(
"1.30"
)
assert (
dv._arith("토사") == "토사" and dv._arith("1/0") == "1/0"
) # 수 식 아니거나 못 세면 그대로
files = store.loaded()[0]
hit = next(
((t["키"], n) for d in files.values() if d.get("그룹") in store.cm.mf.TABLE_GROUPS
for t in d.get("표") or [] for r in t.get("줄") or [] for n in t.get("값칸") or {}
if isinstance(r.get(n), str) and store.cm.mf._ARITH.fullmatch(r[n])),
None,
) # fmt: skip
if hit: # 마스터에 식 글 값이 있으면 표 알약에서 수로 나옴
got = dv._table(hit[0])
col = got["rows"][0].index(hit[1])
assert not any(
isinstance(r[col], str) and store.cm.mf._ARITH.fullmatch(r[col])
for r in got["rows"][1:]
)
item = next(r for r in dv._group_rows("재료") if not str(r.get("키")).startswith("MO"))
got = dv.slot_price({"mode": "rule", "field": "키", "op": "같음", "scope": {}}, item["키"])
assert got.get("ref") == item["키"], got
def test_흐름_셈_단가는_부른_쪽_자리_프로젝트_사본(client: TestClient) -> None:
"""옛 길과 같은 입력 → 같은 단가 — 프로젝트 사본(M02_Item_Prices 의 Master)이면 그 사본 노임."""
flow = _flow()
el = {"ref": "LB000001", "name": "작업반장", "spec": "", "unit": "원/일", "mode": "fixed"}
flow["boxes"].append({"id": "lab", "kind": "input", "name": "노임", "color": "", "unit": "-", "x": 0, "y": 0,
"input": {"mode": "group", "group": "인력", "elements": [el], "variables": []}}) # fmt: skip
flow["links"][2] = {
"id": "l3",
"from": {"box": "lab", "name": "작업반장"},
"to": {"box": "out", "name": "값"},
}
_save_flow(client, KEY, flow)
files = store.cm.load(folder=store.FOLDER)
master = store.cm.mf.run(store.cm.mf.Master(files), KEY, {})
labor = next(r for d in files.values() for r in d.get("줄") or [] if r.get("키") == "LB000001")
assert master["노무비"] == labor["값"]
labor["값"] = Decimal(100000) # 프로젝트 사본 노임
copy = store.cm.mf.run(store.cm.mf.Master(files), KEY, {})
assert copy["노무비"] == 100000 and master["노무비"] != 100000
@@ -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
+184
View File
@@ -0,0 +1,184 @@
"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 ·
화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음).
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => 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(".")) {
const p = 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 m = load(file);
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round));
process.stdout.write(JSON.stringify(out));
setTimeout(() => process.exit(0), 50);
"""
ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}}
def box(id_, kind, name, unit="원", **extra):
return {
"id": id_,
"kind": kind,
"name": name,
"color": "#000",
"unit": unit,
"x": 0,
"y": 0,
**extra,
}
def out_box(id_, name, cost, unit="원", round_=None):
output = {"cost": cost, "unit": unit}
if round_:
output["round"] = round_
return box(id_, "output", name, unit, output=output)
def func_box(id_, name, out_name, unit="시간"):
func = {
"sheet": {"id": "s-" + id_, "이름": name, "칸": {}},
"inputs": [],
"variables": [],
"output": {"cell": "B1", "name": out_name, "unit": unit},
}
return box(id_, "function", name, unit, func=func)
def logic(boxes):
return {
"key": None,
"name": "시험",
"resultUnit": "원/㎡",
"boxes": boxes,
"links": [],
"userElements": [],
}
def result(outputs, cost, ports=None, errors=None):
return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []}
def run(jobs: list[dict]) -> list:
node = "node"
done = subprocess.run(
[node, "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs),
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
@pytest.fixture(scope="module")
def sample():
lg = logic(
[
out_box("o1", "인건비", "노무비"),
out_box("o2", "재료값", "재료비"),
out_box("o3", "시간당 작업량", "값", "㎥/시간"),
out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}),
func_box("f1", "운반 시간", "시간", "시간"),
]
)
ev = result(
{"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239},
{
"노무비": 3216.69,
"재료비": 100.04,
"경비": 1.239,
"값들": {"시간당 작업량": 12.5},
"계": 3317.969,
},
ports={"f1": {"시간": 6.024}},
)
return run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
def group(rows, key):
return next(g for g in rows["groups"] if g["key"] == key)
def test_group_order_and_costs_only(sample):
assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"]
def test_line_uses_output_round(sample):
# 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음)
assert group(sample, "labor")["lines"][0]["text"] == "3,216.6"
assert group(sample, "material")["lines"][0]["text"] == "100"
# 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림)
assert group(sample, "cost")["lines"][0]["text"] == "1.24"
def test_plain_value_is_not_rounded_and_has_no_subtotal(sample):
plain = group(sample, "plain")
assert plain["lines"][0]["text"] == "12.5"
assert plain["lines"][0]["unit"] == "㎥/시간"
assert plain["sub"] is None
def test_subtotal_and_total_floor_to_won(sample):
assert group(sample, "labor")["sub"] == 3216
assert group(sample, "labor")["subText"] == "3,216"
assert sample["total"] == 3317
assert sample["totalText"] == "3,317"
def test_function_rows(sample):
assert sample["funcs"] == [
{"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"}
]
def test_empty_group_is_hidden():
lg = logic([out_box("o1", "인건비", "노무비")])
ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10})
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
assert [g["key"] for g in rows["groups"]] == ["labor"]
assert rows["total"] == 10
def test_missing_value_shows_dash():
lg = logic([out_box("o1", "인건비", "노무비")])
ev = result(
{"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"]
)
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
assert group(rows, "labor")["lines"][0]["text"] == "—"
assert rows["errors"] == ["끊김"]
+2 -2
View File
@@ -1,4 +1,4 @@
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`.
"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`.
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
@@ -21,7 +21,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow"
SAMPLE_TS = FOLDER / "M02_CF_Sample.ts"
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip
HOOKS = r"""
import { readFileSync } from "node:fs";
+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
+268
View File
@@ -0,0 +1,268 @@
"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`).
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
(async () => {
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = [];
for (const j of jobs) {
if (j.name === "wouldCycle") {
const graph = j.args[2];
out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null));
} else out.push(await m[j.name](...j.args));
}
process.stdout.write(JSON.stringify(out));
})();
"""
def call(name: str, *args):
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)[0]
def row(key, name, book, chapter, no="", unit="원/㎡", **more):
return {
"file": f"로직_{book}.json",
"구분": book,
"상세구분": chapter,
"키": key,
"원문번호": no,
"이름": name,
"결과단위": unit,
**more,
}
ITEMS = [
row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"),
row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"),
row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"),
row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"),
row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"),
]
def keys(items):
return [i["키"] for i in items]
def test_찾기는_띄어쓰기로_나눈_말을_모두_포함():
got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"})
assert keys(got) == ["GF000219"]
assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"]
assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [
"GF000219"
] # 띄어쓰기는 가리지 않음
assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [
"GF000219",
"GF000220",
] # 원문번호
assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [
"GC000051"
] # 번호 · 대소문자
assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS
def test_원문_장으로_거름():
assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"]
got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"})
assert keys(got) == ["GF000025"]
assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == []
def test_원문_장_목록은_나온_차례():
assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"]
assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"]
assert call("logicChapters", ITEMS, "") == [
"13장 구조물",
"05장 녹화",
"01장 공통",
"내가 만든 것",
]
def box(id_, kind, name, **more):
return {
"id": id_,
"kind": kind,
"name": name,
"color": "#fff",
"unit": "-",
"x": 0,
"y": 0,
**more,
}
FLOW = {
"key": "GF000219",
"name": "돌쌓기 메쌓기(인력)",
"resultUnit": "원/㎡",
"userElements": [],
"links": [],
"boxes": [
box(
"b1",
"input",
"입력",
input={
"mode": "variable",
"elements": [],
"variables": [
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
{
"name": "돌",
"type": "choice",
"unit": "",
"choices": ["깬돌", "견치돌"],
"example": "깬돌",
},
],
},
),
box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}),
],
}
def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것():
ref = call("masterRef", "GF000219", FLOW, False, "원/㎡")
assert ref["key"] == "GF000219"
assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}]
assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}]
assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"}
def test_옛_로직_변환은_노무비_재료비_경비_계_넷():
ref = call("masterRef", "GF000219", FLOW, True, "원/㎡")
assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"]
assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"}
assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로
def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것():
inner = {
"mode": "logic",
"elements": [],
"variables": [],
"logic": {
"key": "GF000250",
"outputs": [{"name": "작업량", "unit": "㎥/시간"}],
"designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}],
"examples": {"지역": "서울"},
},
}
flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]}
flow["links"] = [
{"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}}
]
ref = call("logicRefOf", "X", flow)
names = [d["name"] for d in ref["designs"]]
assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌
assert ref["examples"]["지역"] == "서울"
def test_자기_자신과_돌고_도는_로직은_막음():
graph = {
"A": {
"boxes": [
box(
"b",
"input",
"x",
input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}},
)
]
},
"B": {
"boxes": [
box(
"b",
"input",
"x",
input={
"mode": "logic",
"elements": [],
"variables": [],
"logic": {"key": "SELF"},
},
)
]
},
"C": {"boxes": []},
}
assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신
assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF
assert call("wouldCycle", "SELF", "C", graph) is False
assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음
assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음
def test_조건_찾기는_인력_기계_유가전력에_쓰고_이름_또는_키가_같은_줄():
labor = {"키": "LB000033", "원문번호": "1033", "이름": "석공"}
assert call("matchesRow", labor, "이름", "석공") is True
assert call("matchesRow", labor, "이름", "석 공") is True # 띄어쓰기는 가리지 않음
assert call("matchesRow", labor, "이름", "할석공") is False # 같음이라 일부만 맞아선 안 잡음
assert call("matchesRow", labor, "키", "lb000033") is True
assert call("matchesRow", labor, "키", "1033") is True # 원문번호도 키로 침
assert call("matchesRow", labor, "이름", "") is False # 알약 값이 비면 안 잡음
def test_조건_찾기_요소와_검사():
port = {"box": "b2", "name": "직종"}
el = call("groupRuleElement", "노임", "이름", port)
assert (
el["mode"] == "rule" and el["ref"] == "" and el["name"] == "노임" and el["slot"] == "노임"
)
assert el["rule"] == {"field": "이름", "op": "같음", "from": port}
assert call("checkGroupRule", el, []) is None
assert call("checkGroupRule", {**el, "name": ""}, []) == "자리 이름을 적어 주세요"
assert call("checkGroupRule", el, [el]) == "같은 이름의 알약이 이미 있음"
no_pill = call("groupRuleElement", "노임", "이름", {"box": "", "name": ""})
assert "변수 알약" in call("checkGroupRule", no_pill, [])
def test_옛_줄_입력과_자동값으로_다른_로직_입력을_읽음():
inputs = [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "돌"}, {"이름": "쌓기"}]
auto = {"뒷길이": 25, "돌": "깬잡석", "쌓기": "", "남는": 1}
ref = call("rowRef", "GF000219", "돌쌓기 메쌓기(인력)", "원/㎡", inputs, auto)
assert ref["key"] == "GF000219"
assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"]
assert ref["designs"] == [
{"name": "뒷길이", "unit": "㎝"},
{"name": "돌", "unit": "-"},
{"name": "쌓기", "unit": "-"},
]
assert ref["examples"] == {"뒷길이": 25, "돌": "깬잡석"} # 빈 글 · 입력에 없는 이름은 뺌
+3 -1
View File
@@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None:
def _ground() -> dict:
return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8"))
return json.loads(
(ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8")
)
def _server(lg: dict, values: dict) -> dict:
+83
View File
@@ -0,0 +1,83 @@
"""오른쪽 흐름도 패널(`M02_DF_Panel.ts`) 순수 부분 — 끌어서 정한 너비 · 저장해 둔 너비.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 판은 빈 흉내 · 순수 함수만).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Panel.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((j) =>
j.op === "drag" ? m.dragWidth(j.start, j.startX, j.x, j.viewport)
: j.op === "stored" ? m.storedWidth(j.raw)
: j.op === "consts" ? [m.PANEL_MIN, m.STRIP_WIDTH]
: 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(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_왼쪽_가장자리_끌기_너비():
[consts, wider, narrower, floor, ceil] = run(
[
{"op": "consts"},
# 왼쪽으로 100 끌면 넓어짐
{"op": "drag", "start": 480, "startX": 1000, "x": 900, "viewport": 1600},
# 오른쪽으로 끌면 좁아짐
{"op": "drag", "start": 480, "startX": 1000, "x": 1060, "viewport": 1600},
# 너무 좁게 — 가장 작은 너비에서 멈춤
{"op": "drag", "start": 480, "startX": 1000, "x": 1400, "viewport": 1600},
# 너무 넓게 — 화면의 85% 에서 멈춤
{"op": "drag", "start": 480, "startX": 1000, "x": -2000, "viewport": 1600},
]
)
min_width, strip = consts
assert (min_width, strip) == (320, 40)
assert wider == 580 and narrower == 420
assert floor == min_width and ceil == 1360
def test_저장해_둔_너비():
got = run(
[
{"op": "stored", "raw": "640"},
{"op": "stored", "raw": "640.6"},
{"op": "stored", "raw": None},
{"op": "stored", "raw": "abc"},
{"op": "stored", "raw": "100"},
]
)
assert got == [640, 641, None, None, None]
+3 -49
View File
@@ -1,4 +1,4 @@
"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움).
견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value)
· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
@@ -6,7 +6,7 @@
· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음
· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 ·
입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
· 파일 안 씀
"""
from __future__ import annotations
@@ -14,7 +14,6 @@ from __future__ import annotations
import json
import math
import re
import subprocess
from decimal import Decimal
from pathlib import Path
@@ -22,8 +21,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow"
SAMPLE_TS = FOLDER / "M02_DF_Sample.ts"
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip
NAMES = ["masonry", "ground", "hume", "linked", "value"]
BOX_COLOR = dict(
re.findall(
@@ -34,40 +32,6 @@ BOX_COLOR = dict(
)
)
HOOKS = r"""
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripTypeScriptTypes } from "node:module";
export async function load(url, ctx, next) {
if (!url.endsWith(".ts")) return next(url, ctx);
let src = readFileSync(fileURLToPath(url), "utf8");
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples");
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => {
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
});
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
}
"""
def _node(body: str) -> object:
files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()}
script = (
"import { register } from 'node:module';\n"
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
"globalThis.__stub = { currentLanguageIndex: 0 };\n"
f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n"
f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n"
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
)
result = subprocess.run(
["node", "--no-warnings", "--input-type=module", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr[-2000:]
return json.loads(result.stdout)
# ── 알약 · 선 ─────────────────────────────────────────────────────────────
def _ports_out(box: dict) -> list[str]:
@@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None:
}
def test_견본_목록_열기() -> None:
got = _node(
"out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });"
)
assert [x["id"] for x in got["list"]] == NAMES
assert all(x["note"] for x in got["list"])
assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"]
assert got["none"] is None
# ── 값(내부 확인) ─────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def master():