Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 []]
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"] == ["끊김"]
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
@@ -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, "돌": "깬잡석"} # 빈 글 · 입력에 없는 이름은 뺌
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user