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

This commit is contained in:
2026-10-01 23:37:14 +09:00
36 changed files with 1750 additions and 1206 deletions
+24 -103
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import { t as L } from "@ui/ui_template_locale";
import {
createButton,
el,
@@ -37,12 +37,7 @@ 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 } from "./M01_MasterData_UI_LogicLab_Flow";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -72,49 +67,6 @@ interface Opened extends Draft {
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 +89,13 @@ export async function mountM01LogicLab(
): Promise<LogicHandle> {
let drafts = loadDrafts();
let opened: Opened | null = null;
let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음
let calcInputs = "";
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 +185,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(
@@ -264,7 +215,6 @@ export async function mountM01LogicLab(
const show = (next: Opened | null): void => {
opened = next;
creating = false;
errors.hidden = true;
drawEditor();
drawCalc();
@@ -326,7 +276,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened || creating) show(null);
if (opened) show(null);
},
}),
);
@@ -410,65 +360,36 @@ 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 saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
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(),
]),
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 +398,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],
}),
],
}),
@@ -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,40 @@
/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */
.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;
}
@@ -0,0 +1,95 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Flow.ts
* 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임)
* 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import "./M01_MasterData_UI_LogicLab_Flow.css";
const TEXT = {
Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"],
Waiting: ["흐름도 자리", "Flow diagram"],
} as const;
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
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;
}
/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */
export function buildSplit(left: HTMLElement): SplitHandle {
const right = el("div", {
className: "m01lab-flow__right",
children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })],
});
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();
const apply = (): void => {
root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`;
};
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();
});
apply();
return { root, right };
}
@@ -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,207 @@
/* =============================================================================
* 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 type { CfBox, 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",
};
/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */
interface FormulaLine {
name: string;
named: string;
valued: string;
branches?: { when: string; then: string; now: boolean }[];
}
type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[];
const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>(
"../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts",
);
const loadFormula = async (): Promise<FormulaFn | null> => {
const load = Object.values(FORMULA)[0];
return load ? ((await load()).formulaLines ?? null) : null;
};
const section = (title: string, body: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01-logic__section m01lab__box",
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 })),
]);
const bodies = rows.funcs.map((f) => funcBlock(f, null));
const text = section(
tf("Formula"),
bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })],
);
host.replaceChildren(detail, text);
// sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함
void loadFormula().then((fn) => {
if (!fn || !bodies.length) return;
const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func);
const merged = { ...ev.ports, ...ev.arrived } as CfValues;
text.replaceChildren(
text.firstElementChild!,
...rows.funcs.map((f, i) => {
try {
return funcBlock(f, fn(boxes[i], merged));
} catch {
return bodies[i];
}
}),
);
});
}
@@ -1,105 +0,0 @@
/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */
.m01lab-new {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01lab-new__step {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__text {
width: 100%;
min-height: 140px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
color: var(--color-text);
font-family: ui-monospace, Consolas, monospace;
font-size: var(--text-body);
resize: vertical;
}
.m01lab-new__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m01lab-new__vars {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01lab-new__var {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01lab-new__var-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
}
.m01lab-new__var-body {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8);
margin-top: var(--spacing-8);
}
.m01lab-new__wide {
flex: 1 1 220px;
}
.m01lab-new__picked {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__src {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-paper);
overflow-x: auto;
}
.m01lab-new__no {
flex: 0 0 auto;
color: var(--color-text-secondary);
}
.m01lab-new__src mark {
background: var(--color-danger);
color: var(--color-bg);
border-radius: 2px;
}
.m01lab-new__bad {
color: var(--color-danger);
}
.m01lab-new__soft {
color: var(--color-text-secondary);
}
.m01lab-new__calc {
min-width: 0;
}
@@ -1,272 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New.ts
* 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장)
* ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름
* ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX)
* 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`)
* ========================================================================== */
import {
createButton,
createInputField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
draftLogic,
type DraftAnswer,
type DraftDecided,
type DraftHead,
type DraftProblem,
type DraftVar,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var";
import "./M01_MasterData_UI_LogicLab_New.css";
/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */
const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요
넓이 = 길이 * 폭
인부 = 넓이 * 품
잡재료비 = 노무비 * 0.05
공사비 = 인부 * 보통인부 + 잡재료비
`;
export interface NewOptions {
/** 저장 끝 — 새 자체 로직의 키 */
onSaved: (key: string) => void;
onClose: () => void;
}
export function buildNewLogic(opts: NewOptions): HTMLElement {
const decided: Record<string, DraftDecided> = {};
const rows = new Map<string, VarRow>();
const values: Record<string, string> = {};
let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림
let answer: DraftAnswer | null = null;
let calcSig = "";
let seq = 0;
const text = el("textarea", {
className: "m01lab-new__text",
attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") },
});
text.value = SAMPLE;
const stale = el("p", {
className: "m01-logic__muted",
text: tn("Stale"),
attrs: { hidden: "" },
});
const varsHost = el("div", { className: "m01lab-new__vars" });
const problemHost = el("div", { className: "m01lab-new__problems" });
const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" });
const nameBox = createInputField({
type: "text",
label: tn("Name"),
onInput: () => onEdit(),
});
nameBox.input.setAttribute("data-key", "name");
const unitBox = createInputField({
type: "text",
label: tn("ResultUnit"),
placeholder: "원/㎡",
onInput: () => onEdit(),
});
unitBox.input.setAttribute("data-key", "unit");
const ownerBox = createInputField({ type: "text", label: tn("Owner") });
const state = el("p", { className: "m01-logic__muted" });
const save = createButton({ label: tn("Save"), onClick: () => void doSave() });
save.setAttribute("data-act", "save");
save.disabled = true;
const head = (): DraftHead | undefined => {
const 이름 = nameBox.input.value.trim();
const 결과단위 = unitBox.input.value.trim();
return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined;
};
/** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */
const drawProblems = (problems: DraftProblem[]): void => {
if (!analyzed) return problemHost.replaceChildren();
const lines = analyzed.split("\n");
const flagged = new Map<number, DraftProblem[]>();
for (const p of problems) {
if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]);
}
const shown = [...flagged.keys()]
.sort((a, b) => a - b)
.filter((n) => n <= lines.length)
.map((n) => {
const raw = lines[n - 1];
const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1;
return el("div", {
className: "m01lab-new__src",
children: [
el("span", { className: "m01lab-new__no", text: String(n) }),
at >= 0
? el("code", {
children: [
raw.slice(0, at),
el("mark", { text: raw.slice(at, at + 1) }),
raw.slice(at + 1),
],
})
: el("code", { text: raw }),
],
});
});
const items = problems.map((p) =>
el("li", {
className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad",
attrs: { "data-kind": p.갈래 },
text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`,
}),
);
problemHost.replaceChildren(
el("h4", { text: tn("Problems") }),
...(items.length
? [...shown, el("ul", { children: items })]
: [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]),
);
};
const drawCalc = (): void => {
const row = answer?.logic ?? null;
save.disabled = !row || (answer?.문제.length ?? 1) > 0;
state.textContent = !answer
? ""
: answer.문제.some((p) => p.갈래 !== "덜정함")
? tn("SaveBlocked")
: !head()
? tn("NeedHead")
: answer.다됨
? ""
: tn("Undecided");
if (!row) {
calcSig = "";
calcHost.replaceChildren();
return;
}
const sig = JSON.stringify(row.입력 ?? []);
if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림
calcSig = sig;
buildCalc(calcHost, {
savedKey: null,
row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄
dirty: () => true,
values,
onLines: () => undefined,
});
};
const drawVars = (vars: DraftVar[]): void => {
rows.clear();
const shown = vars.filter((v) => v.갈래 !== "비목합");
for (const name of Object.keys(decided)) {
if (!shown.some((v) => v.이름 === name)) delete decided[name];
}
const result = answer?.결과 ?? "";
const order = [
...shown.filter((v) => v.갈래 === "미정"),
...shown.filter((v) => v.갈래 !== "미정"),
];
for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit }));
varsHost.replaceChildren(...[...rows.values()].map((r) => r.root));
};
const evaluate = async (rebuild: boolean): Promise<void> => {
if (!analyzed) return;
const mine = ++seq;
try {
const got = await draftLogic({ text: analyzed, decided, head: head() });
if (mine !== seq) return; // 최신 응답만
answer = got;
if (rebuild) drawVars(got.변수);
for (const v of got.변수) rows.get(v.이름)?.update(v);
drawProblems(got.문제);
drawCalc();
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
let timer = 0;
function onEdit(): void {
window.clearTimeout(timer);
timer = window.setTimeout(() => void evaluate(false), 250);
}
const analyzeButton = createButton({
label: tn("Analyze"),
onClick: () => {
analyzed = text.value;
stale.hidden = true;
void evaluate(true);
},
});
analyzeButton.setAttribute("data-act", "analyze");
text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed));
async function doSave(): Promise<void> {
const h = head();
if (!h) return void showToast(tn("NeedHead"), "info");
save.disabled = true;
showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림
try {
const owner = ownerBox.input.value.trim();
const done = await draftLogic({
text: analyzed,
decided,
head: h,
save: true,
...(owner ? { owner } : {}),
});
showToast(tn("Saved", { v: done.key ?? "" }), "success");
if (done.key) opts.onSaved(done.key);
} catch (error) {
const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null;
if (detail?.문제) drawProblems(detail.문제);
else showToast(error instanceof Error ? error.message : tn("Failed"), "error");
save.disabled = false;
} finally {
hideLoadingOverlay();
}
}
const section = (title: string, ...children: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01lab__box m01lab-new__step",
children: [el("h3", { text: title }), ...children],
});
return el("div", {
className: "m01lab-new",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h2", { text: tn("Title") }),
createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }),
],
}),
section(
tn("Step1"),
el("p", { className: "m01-logic__muted", text: tn("Guide") }),
text,
el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }),
),
section(tn("Step2"), varsHost, problemHost),
section(
tn("Step3"),
el("div", {
className: "m01lab-new__row",
children: [nameBox.root, unitBox.root, ownerBox.root],
}),
),
section(tn("Step4"), calcHost, state, save),
],
});
}
@@ -1,218 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Find.ts
* 새 로직 만들기의 고르기 창 둘
* ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기
* ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubsAll,
type FileInfo,
type SubInfo,
} from "./M01_MasterData_Api_Fetch";
import type { DraftFind } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface PickedElement {
ref: string;
/** 이름 + 규격 */
name: string;
/** 수량에 쓰는 단위(「원/일」 → 「일」) */
unit: string;
group: string;
}
const GROUPS = ["인력", "재료", "기계"];
const SHOWN = 50;
/** 창 뼈대 — 제목 · 닫기 · 몸통 */
function shell(title: string, body: HTMLElement[]): { close: () => void } {
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": title },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: title }),
createButton({ label: tn("Close"), variant: "ghost", onClick: close }),
],
}),
...body,
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
return { close };
}
/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */
async function fileOf(group: string): Promise<FileInfo | undefined> {
const files = await fetchFiles(group);
return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0];
}
const options = (values: string[]): { value: string; text: string }[] => [
{ value: "", text: tn("All") },
...values.map((v) => ({ value: v, text: v })),
];
export function openElementPick(done: (picked: PickedElement) => void): void {
let file: FileInfo | undefined;
let subs: SubInfo[] = [];
const groupBox = createSelectField({
options: GROUPS.map((g) => ({ value: g, text: g })),
value: GROUPS[0],
compact: true,
label: tn("Group"),
});
const subBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Sub"),
});
const detailBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Detail"),
});
const searchField = createInputField({ type: "search", placeholder: tn("Search") });
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const win = shell(tn("K_master"), [
el("div", {
className: "m01-logic__pick-bar",
children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count],
}),
list,
]);
let seq = 0;
const load = async (): Promise<void> => {
if (!file) return;
const mine = ++seq;
try {
const got = await fetchRows(
file.file,
1,
SHOWN,
searchField.input.value.trim(),
false,
subBox.select.value,
detailBox.select.value,
);
if (mine !== seq) return;
count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) });
list.replaceChildren(
...got.rows.map((row) => {
const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
const unitText = String(row["단위"] ?? "");
const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? "");
const button = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-ref": String(row["키"]) },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: name }),
el("span", { className: "m01-logic__muted", text: String(row["키"]) }),
],
}),
el("span", {
className: "m01-logic__money",
text: `${formatNumber(row["값"])} ${unitText}`.trim(),
}),
],
});
button.addEventListener("click", () => {
win.close();
done({ ref: String(row["키"]), name, unit, group: groupBox.select.value });
});
return button;
}),
);
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
const changeGroup = async (): Promise<void> => {
file = await fileOf(groupBox.select.value);
subs = file ? (await fetchSubsAll({ file: file.file })).subs : [];
subBox.setOptions(options(subs.map((s) => s.name)), "");
detailBox.setOptions(options([]), "");
detailBox.select.disabled = true;
await load();
};
groupBox.select.addEventListener("change", () => void changeGroup());
subBox.select.addEventListener("change", () => {
const details = subs.find((s) => s.name === subBox.select.value)?.details ?? [];
detailBox.setOptions(options(details), "");
detailBox.select.disabled = details.length === 0;
void load();
});
detailBox.select.addEventListener("change", () => void load());
let timer = 0;
searchField.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void load(), 250);
});
void changeGroup();
}
/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */
export function openTablePick(done: (find: DraftFind, label: string) => void): void {
openPicker("table", "소요량", (item, column) => {
void (async () => {
const table = await loadTable(item.ref);
const conds = Object.keys(table?.조건 ?? {});
const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`;
const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record<string, string> };
if (!conds.length) {
done(find, label);
return;
}
const inputs = conds.map((c) => {
const box = createInputField({ type: "text", value: c });
box.input.setAttribute("data-cond", c);
return { c, box };
});
const win = shell(tn("TableTitle"), [
el("p", { className: "m01-logic__muted", text: label }),
el("p", { text: tn("Cond") }),
...inputs.map(({ c, box }) =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: c }), box.root],
}),
),
createButton({
label: tn("Ok"),
onClick: () => {
for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c;
win.close();
done(find, label);
},
}),
]);
})();
});
}
@@ -1,71 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Text.ts
* 「식으로 새 로직 만들기」 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Open: ["식으로 새로 만들기", "New from formula"],
Title: ["식으로 새 로직 만들기", "New logic from a formula"],
Close: ["닫기", "Close"],
Step1: ["① 식 적기", "① Write the formula"],
Guide: [
"한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀",
"One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped",
],
Analyze: ["분석", "Analyze"],
Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"],
Step2: ["② 변수 정하기", "② Decide each variable"],
Step3: ["③ 이름·단위", "③ Name and unit"],
Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"],
Problems: ["고칠 곳", "To fix"],
Line: ["{n}번 줄", "Line {n}"],
AllGood: ["검사에 걸린 것 없음", "No problems found"],
Result: ["결과", "Result"],
Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"],
Kind: ["무엇인가", "What is it"],
K_master: ["마스터 요소", "Master element"],
K_input: ["설계 입력", "Design input"],
K_fixed: ["고정값", "Fixed value"],
K_table: ["표 찾기", "Table lookup"],
Pick: ["고르기", "Pick"],
Repick: ["다시 고르기", "Pick again"],
NotPicked: ["아직 안 골랐음", "Not picked"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost item"],
CostNone: ["넣지 않음", "None"],
CostExtra: ["덧줄 비목", "Extra-line cost item"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Derived: ["식으로 정해짐", "Set by the formula"],
SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"],
Name: ["로직 이름", "Logic name"],
ResultUnit: ["결과 단위", "Result unit"],
Owner: ["소유", "Owner"],
Save: ["저장", "Save"],
Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"],
SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"],
NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"],
Group: ["종류", "Kind"],
Sub: ["구분", "Group"],
Detail: ["상세구분", "Detail"],
All: ["(전체)", "(All)"],
Search: ["이름 찾기", "Search name"],
Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"],
TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"],
TablePick: ["표 고르기", "Pick a table"],
TableCol: ["값 칸", "Value column"],
Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
Failed: ["못 함", "Failed"],
} as const satisfies Record<string, readonly [string, string]>;
export type NewTextKey = keyof typeof TEXT;
export function tn(key: NewTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
@@ -1,178 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Var.ts
* 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름
* 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api";
import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface VarCtx {
decided: Record<string, DraftDecided>;
/** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */
onEdit: () => void;
}
export interface VarRow {
root: HTMLElement;
/** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */
update: (v: DraftVar) => void;
}
const KINDS: [string, () => string][] = [
["마스터요소", () => tn("K_master")],
["설계입력", () => tn("K_input")],
["고정값", () => tn("K_fixed")],
["표찾기", () => tn("K_table")],
];
const COSTS = ["노무비", "재료비", "경비"];
/** 요소 종류 → 비목 첫 제안 */
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
const costOptions = (): { value: string; text: string }[] => [
{ value: "", text: tn("CostNone") },
...COSTS.map((c) => ({ value: c, text: c })),
];
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
});
export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow {
const name = v.이름;
const info = el("span", { className: "m01-logic__muted m01lab-new__derived" });
const body = el("div", { className: "m01lab-new__var-body" });
const root = el("div", {
className: "m01lab-new__var",
attrs: { "data-var": name },
children: [
el("div", {
className: "m01lab-new__var-head",
children: [el("strong", { text: name }), info],
}),
body,
],
});
const now = (): DraftDecided => (ctx.decided[name] ??= {});
const set = (patch: DraftDecided): void => {
ctx.decided[name] = { ...now(), ...patch };
for (const [k, val] of Object.entries(ctx.decided[name])) {
if (val === "" || val === undefined) delete (ctx.decided[name] as Record<string, unknown>)[k];
}
ctx.onEdit();
};
const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => {
const box = createInputField({
type: "text",
value: now()[key] ?? "",
onInput: (val) => set({ [key]: val.trim() }),
});
box.input.setAttribute("data-key", key);
const cell = field(label, box.root);
if (wide) cell.classList.add("m01lab-new__wide");
return cell;
};
let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글
const draw = (): void => {
const kind = now().무엇 ?? "";
if (v.갈래 === "비목합") {
body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") }));
return;
}
if (v.갈래 === "정의됨") {
if (isResult) return body.replaceChildren();
const cost = createSelectField({
options: costOptions(),
value: now().비목 ?? "",
compact: true,
label: tn("CostExtra"),
onChange: (val) => set({ 비목: val }),
});
body.replaceChildren(cost.root);
return;
}
const kindBox = createSelectField({
options: [
{ value: "", text: "—" },
...KINDS.map(([value, label]) => ({ value, text: label() })),
],
value: kind,
compact: true,
label: tn("Kind"),
onChange: (val) => {
ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {};
picked = "";
draw();
ctx.onEdit();
},
});
const parts: HTMLElement[] = [kindBox.root];
if (kind === "마스터요소") {
const cost = createSelectField({
options: costOptions(),
value: now().비목 ?? "",
compact: true,
label: tn("Cost"),
onChange: (val) => set({ 비목: val }),
});
const choose = createButton({
label: now().요소 ? tn("Repick") : tn("Pick"),
variant: "ghost",
onClick: () =>
openElementPick((p) => {
picked = `${p.name} · ${p.ref}`;
set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 });
draw();
}),
});
choose.setAttribute("data-act", "pick-element");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
cost.root,
);
} else if (kind === "설계입력") {
parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true));
} else if (kind === "고정값") {
parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위"));
} else if (kind === "표찾기") {
const choose = createButton({
label: now().찾기 ? tn("Repick") : tn("TablePick"),
variant: "ghost",
onClick: () =>
openTablePick((find, label) => {
picked = label;
set({ 찾기: find });
draw();
}),
});
choose.setAttribute("data-act", "pick-table");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
);
}
body.replaceChildren(...parts);
};
draw();
const update = (next: DraftVar): void => {
v = next;
info.textContent =
next.갈래 === "정의됨"
? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}`
: (next.단위 ?? "");
};
update(v);
return { root, update };
}
@@ -81,7 +81,7 @@ const TEXT = {
"지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음",
"No row matches the current values — change them, or this table is on an unused branch",
],
Copy_Open: ["본떠 만들기", "Copy as mine"],
Copy_Open: ["로직 복사/생성", "Copy / create logic"],
Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"],
Copy_Title: ["복사해서 만들기", "Copy to edit"],
Copy_Name: ["새 이름", "New name"],
+19
View File
@@ -206,6 +206,25 @@ export async function cfLogicValue(
return send("/logic-value", { key, designs });
}
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
export async function cfLogicFlow(
file: string,
key: string,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
credentials: "include",
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data as { flow: CfLogic; converted: boolean; notes: string[] };
}
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
const qs = new URLSearchParams({ key }).toString();
+30 -48
View File
@@ -23,6 +23,9 @@ import {
restParts,
linkLabel,
midOf,
pillInfo,
svgAdd as add,
svgLabel as label,
pillsOf,
resolveDrop,
showValue,
@@ -46,6 +49,7 @@ interface HostState {
panX: number;
panY: number;
fitted: boolean;
fitKey?: number;
watch?: ResizeObserver;
}
@@ -79,6 +83,9 @@ export function renderDiagram(
fitted: false,
};
if (opts.selected !== undefined) st.selected = opts.selected;
if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey)
[st.fitKey, st.fitted] = [opts.fitKey, false];
const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨)
if (opts.selectedPill !== undefined) st.pill = opts.selectedPill;
states.set(host, st);
const values = opts.values ?? {};
@@ -243,7 +250,7 @@ export function renderDiagram(
className: "m02-dfdg__ins",
children: [
...ins.map((p) => pillEl(b, p, "in")),
...(b.kind === "function" ? [plusSlot(b)] : []),
...(b.kind === "function" && !ro ? [plusSlot(b)] : []),
],
}),
el("div", {
@@ -282,13 +289,13 @@ export function renderDiagram(
ArrowUp: [0, -step],
ArrowDown: [0, step],
}[ev.key];
if (d && st.selected === b.id) {
if (d && st.selected === b.id && !ro) {
ev.preventDefault();
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
}
});
head.addEventListener("pointerdown", (ev) => {
if (ev.button === 0 && !spaceDown) dragBox(ev, b, node);
if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node);
});
return node;
};
@@ -350,15 +357,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 +371,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 +388,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);
}),
...(ro
? []
: [
sm("Arrange", () =>
Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)),
),
]),
],
});
const root = el("div", {
@@ -426,7 +434,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 +485,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 +639,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,10 @@ export interface DiagramOpts {
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
/** 끊긴 곳 — Eval breaks */
breaks?: CfBreak[];
/** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */
readOnly?: boolean;
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
fitKey?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -330,6 +334,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 +383,37 @@ export function follow(
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */
const SVG_NS = "http://www.w3.org/2000/svg";
/** SVG 마디 하나 붙이기 */
export function svgAdd<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attrs: Record<string, string>,
): SVGElementTagNameMap[K] {
const n = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.append(n);
return n;
}
/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */
export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number {
const tag = svgAdd(parent, "g", { class: cls });
const box = svgAdd(tag, "rect", { rx: "4", ry: "4" });
const t = svgAdd(tag, "text", {
x: x.toFixed(1),
y: y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = text;
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
return w;
}
+1 -1
View File
@@ -70,7 +70,7 @@
/* 시작 화면 */
.m02-df__start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-24);
}
+2 -24
View File
@@ -47,12 +47,11 @@ const loadMods = () =>
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
import("./M02_DF_PillPanel"),
import("./M02_DF_LinkPanel"),
import("./M02_DF_Standards"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({
]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({
model,
ev,
api,
@@ -60,7 +59,6 @@ const loadMods = () =>
func,
output,
summary,
sample,
input,
pill,
line,
@@ -78,7 +76,6 @@ const TEXT = {
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
@@ -217,7 +214,6 @@ function openDataFlow(): void {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
const sampleHost = el("div", { className: "m02-df__list" });
body.replaceChildren(
el("div", {
className: "m02-df__start",
@@ -232,28 +228,10 @@ function openDataFlow(): void {
mineHost,
],
}),
el("section", {
className: "m02-df__start-col",
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
}),
],
}),
);
const M = await mods;
sampleHost.replaceChildren(
...M.sample.sampleList().map((s) =>
listRow(
s.name,
s.resultUnit,
() =>
run(async (M2) => {
const lg = M2.sample.sampleLoad(s.id);
if (lg) await showPaper(M2, lg, false);
}),
s.note,
),
),
);
try {
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
mineHost.replaceChildren(
@@ -379,7 +357,7 @@ function openDataFlow(): void {
if (sig === logicSig) return;
logicSig = sig;
const get = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key);
key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
@@ -236,19 +236,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 +265,93 @@ 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;
}
/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `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 {
@@ -1,42 +1,87 @@
/* =============================================================================
* 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 { 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,
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 +91,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 +134,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 +222,114 @@ 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 picked = 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 +349,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)),
);
}
+341
View File
@@ -0,0 +1,341 @@
/* =============================================================================
* 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>;
}
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,
});
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;
@@ -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;
}
+184
View File
@@ -0,0 +1,184 @@
"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 ·
화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음).
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) });
const cache = {};
function load(f) {
if (cache[f]) return cache[f].exports;
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = (cache[f] = { exports: {} });
const req = (id) => {
if (id.startsWith(".")) {
const p = path.resolve(path.dirname(f), id);
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
}
return stub;
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const m = load(file);
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round));
process.stdout.write(JSON.stringify(out));
setTimeout(() => process.exit(0), 50);
"""
ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}}
def box(id_, kind, name, unit="원", **extra):
return {
"id": id_,
"kind": kind,
"name": name,
"color": "#000",
"unit": unit,
"x": 0,
"y": 0,
**extra,
}
def out_box(id_, name, cost, unit="원", round_=None):
output = {"cost": cost, "unit": unit}
if round_:
output["round"] = round_
return box(id_, "output", name, unit, output=output)
def func_box(id_, name, out_name, unit="시간"):
func = {
"sheet": {"id": "s-" + id_, "이름": name, "칸": {}},
"inputs": [],
"variables": [],
"output": {"cell": "B1", "name": out_name, "unit": unit},
}
return box(id_, "function", name, unit, func=func)
def logic(boxes):
return {
"key": None,
"name": "시험",
"resultUnit": "원/㎡",
"boxes": boxes,
"links": [],
"userElements": [],
}
def result(outputs, cost, ports=None, errors=None):
return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []}
def run(jobs: list[dict]) -> list:
node = "node"
done = subprocess.run(
[node, "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs),
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
@pytest.fixture(scope="module")
def sample():
lg = logic(
[
out_box("o1", "인건비", "노무비"),
out_box("o2", "재료값", "재료비"),
out_box("o3", "시간당 작업량", "값", "㎥/시간"),
out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}),
func_box("f1", "운반 시간", "시간", "시간"),
]
)
ev = result(
{"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239},
{
"노무비": 3216.69,
"재료비": 100.04,
"경비": 1.239,
"값들": {"시간당 작업량": 12.5},
"계": 3317.969,
},
ports={"f1": {"시간": 6.024}},
)
return run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
def group(rows, key):
return next(g for g in rows["groups"] if g["key"] == key)
def test_group_order_and_costs_only(sample):
assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"]
def test_line_uses_output_round(sample):
# 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음)
assert group(sample, "labor")["lines"][0]["text"] == "3,216.6"
assert group(sample, "material")["lines"][0]["text"] == "100"
# 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림)
assert group(sample, "cost")["lines"][0]["text"] == "1.24"
def test_plain_value_is_not_rounded_and_has_no_subtotal(sample):
plain = group(sample, "plain")
assert plain["lines"][0]["text"] == "12.5"
assert plain["lines"][0]["unit"] == "㎥/시간"
assert plain["sub"] is None
def test_subtotal_and_total_floor_to_won(sample):
assert group(sample, "labor")["sub"] == 3216
assert group(sample, "labor")["subText"] == "3,216"
assert sample["total"] == 3317
assert sample["totalText"] == "3,317"
def test_function_rows(sample):
assert sample["funcs"] == [
{"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"}
]
def test_empty_group_is_hidden():
lg = logic([out_box("o1", "인건비", "노무비")])
ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10})
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
assert [g["key"] for g in rows["groups"]] == ["labor"]
assert rows["total"] == 10
def test_missing_value_shows_dash():
lg = logic([out_box("o1", "인건비", "노무비")])
ev = result(
{"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"]
)
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
assert group(rows, "labor")["lines"][0]["text"] == "—"
assert rows["errors"] == ["끊김"]
+2 -2
View File
@@ -1,4 +1,4 @@
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`.
"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`.
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
@@ -21,7 +21,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow"
SAMPLE_TS = FOLDER / "M02_CF_Sample.ts"
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip
HOOKS = r"""
import { readFileSync } from "node:fs";
+230
View File
@@ -0,0 +1,230 @@
"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `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 # 못 읽으면 막지 않음
+3 -1
View File
@@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None:
def _ground() -> dict:
return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8"))
return json.loads(
(ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8")
)
def _server(lg: dict, values: dict) -> dict:
+3 -49
View File
@@ -1,4 +1,4 @@
"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움).
견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value)
· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
@@ -6,7 +6,7 @@
· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음
· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 ·
입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
· 파일 안 씀
"""
from __future__ import annotations
@@ -14,7 +14,6 @@ from __future__ import annotations
import json
import math
import re
import subprocess
from decimal import Decimal
from pathlib import Path
@@ -22,8 +21,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow"
SAMPLE_TS = FOLDER / "M02_DF_Sample.ts"
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip
NAMES = ["masonry", "ground", "hume", "linked", "value"]
BOX_COLOR = dict(
re.findall(
@@ -34,40 +32,6 @@ BOX_COLOR = dict(
)
)
HOOKS = r"""
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripTypeScriptTypes } from "node:module";
export async function load(url, ctx, next) {
if (!url.endsWith(".ts")) return next(url, ctx);
let src = readFileSync(fileURLToPath(url), "utf8");
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples");
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => {
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
});
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
}
"""
def _node(body: str) -> object:
files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()}
script = (
"import { register } from 'node:module';\n"
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
"globalThis.__stub = { currentLanguageIndex: 0 };\n"
f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n"
f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n"
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
)
result = subprocess.run(
["node", "--no-warnings", "--input-type=module", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr[-2000:]
return json.loads(result.stdout)
# ── 알약 · 선 ─────────────────────────────────────────────────────────────
def _ports_out(box: dict) -> list[str]:
@@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None:
}
def test_견본_목록_열기() -> None:
got = _node(
"out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });"
)
assert [x["id"] for x in got["list"]] == NAMES
assert all(x["note"] for x in got["list"])
assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"]
assert got["none"] is None
# ── 값(내부 확인) ─────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def master():