feat(m02-df): 흐름도 잇기 — 고름 · 상세 내용 판 · 흐름 길 · 끊긴 곳 · 다른 로직 · 표준 표

- 고름 = 상자 · 알약{box,port,dir} · 선 — 다이어그램 · 검증 칸 줄 · 알약 판 상자 이름 누름 모두 같은 고름으로
- 상세 내용 본문 = 입력 · 함수 · 출력 판 · 알약 = renderPillPanel · 선 = renderLinkPanel · 경고 = 끊긴 곳 가운데 고른 것 몫
- 알약 · 선 고름 = Model traceBack/traceForward 로 Diagram opts.trace · Eval breaks 로 Diagram opts.breaks
- 다른 로직 입력이 부르는 로직(저장 DF- · 견본)을 겹쳐 읽어 Eval opts.logics 로(셈 · 검사 · 판 모두)
- 「표준 표」 단추 = renderStandards 판(공용 모달)
- 입력 값 물음 · 결과 단위 후보는 Entry_Detail 로 옮김(700 줄 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-01 20:00:56 +09:00
co-authored by Claude Opus 5.5
parent 314783a027
commit ce65d1df3b
2 changed files with 227 additions and 186 deletions
+127 -116
View File
@@ -18,17 +18,21 @@ import {
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfListItem, CfQuery } from "./M02_DF_Api";
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model";
import type { CfListItem } from "./M02_DF_Api";
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
import type { CfSel } from "./M02_DF_Verify";
import type { DfPillSel } from "./M02_DF_Diagram_Parts";
import {
detailFrame,
drawHead,
drawWarnings,
lineBody,
pillBody,
fetchValues,
loadLogics,
queriesOf,
unitChoices,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Entry.css";
@@ -45,8 +49,10 @@ const loadMods = () =>
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
import("./M02_DF_Rule"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input, rule]) => ({
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]) => ({
model,
ev,
api,
@@ -56,7 +62,9 @@ const loadMods = () =>
summary,
sample,
input,
rule,
pill,
line,
std,
}));
type Mods = Awaited<ReturnType<typeof loadMods>>;
@@ -81,6 +89,8 @@ const TEXT = {
Diagram: ["다이어그램", "Diagram"],
Summary: ["원가", "Cost"],
Color: ["상자 색", "Box colour"],
Standards: ["표준 표", "Standards"],
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
SaveAnyway: [
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
@@ -113,63 +123,6 @@ const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
async function unitChoices(): Promise<string[]> {
const count = new Map<string, number>();
try {
for (const x of await fetchLogics()) {
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
}
} catch {
/* 못 받으면 기본 하나 */
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
}
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const els = inp.elements
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
.map((e) => {
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
if (field) q.field = field;
return { q, box: b.id, port: e.name };
});
const table = inp.table
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
: [];
return [...els, ...table];
});
}
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
const asks = queriesOf(lg);
if (!asks.length) return { values: {}, errors: [] };
const answers = await M.api.cfValues(
asks.map((a) => a.q),
lg.userElements as never,
);
const values: CfValues = {};
const errors: string[] = [];
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
}
return { values, errors };
}
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createDataFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow });
@@ -330,6 +283,10 @@ function openDataFlow(): void {
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
let values: CfValues = {};
let valueSig = "";
let logics: NonNullable<CfEvalOptions["logics"]> = {};
let logicSig = "";
let logicNotes: string[] = [];
let broken: CfBreak[] = [];
let notes: string[] = [];
let result: CfEvalResult;
let busy = false;
@@ -355,15 +312,15 @@ function openDataFlow(): void {
const outHost = el("div");
const inHost = el("div");
const summaryHost = el("div");
const lineHost = el("div");
const linkHost = el("div");
const pillHost = el("div");
const detail = detailFrame();
detail.settings.append(colorRow);
detail.body.append(inHost, funcHost, outHost, lineHost, pillHost);
detail.body.append(inHost, funcHost, outHost, linkHost, pillHost);
const evalNow = (): CfEvalResult => {
try {
return M.ev.evalLogic(lg, values);
return M.ev.evalLogic(lg, values, { logics });
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
@@ -387,7 +344,7 @@ function openDataFlow(): void {
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(M, lg).then(
void fetchValues(M.api.cfValues, lg).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
@@ -401,6 +358,54 @@ function openDataFlow(): void {
);
};
/** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */
const refreshLogics = (): void => {
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
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);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
logicNotes = got.errors.length ? [te("Logics", { v: 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: M.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: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) };
}
const id = pick?.kind === "link" ? pick.id : "";
const link = lg.links.find((l) => l.id === id);
if (link)
return {
back: M.model.traceBack(lg, link.from),
forward: M.model.traceForward(lg, link.to),
};
} catch {
/* 따라갈 수 없으면 칠하지 않음 */
}
return null;
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
@@ -417,18 +422,16 @@ function openDataFlow(): void {
});
const diagramOn = {
select: (id: string | null): void => {
pick = !id
? null
: lg.links.some((l) => l.id === id)
? { kind: "link", id }
: { kind: "box", id };
drawPanel();
},
/** 알약 고름 — 다이어그램(sub8)이 알려 주면 상세 내용이 알약 판으로 */
pill: (p: { box: string; name: string; dir: "in" | "out" } | null): void => {
pick = p ? { kind: "pill", ...p } : null;
drawPanel();
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(M.model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
@@ -455,35 +458,20 @@ function openDataFlow(): void {
const box = lg.boxes.find((b) => b.id === pickedBox());
drawHead(detail, lg, pick);
colorRow.hidden = !box;
lineHost.hidden = pick?.kind !== "link";
linkHost.hidden = pick?.kind !== "link";
pillHost.hidden = pick?.kind !== "pill";
if (pick?.kind === "link") {
const linkId = pick.id;
const rule = lg.links.find((l) => l.id === linkId)?.rule;
let text: string | null = null;
try {
text = rule ? M.rule.ruleLabel(rule) : null;
} catch {
text = rule ? rule.kind : null;
}
lineBody(lineHost, lg, pick.id, text);
}
if (pick?.kind === "pill") {
const port = { box: pick.box, name: pick.name };
pillBody(
if (pick?.kind === "link")
M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
if (pick?.kind === "pill")
M.pill.renderPillPanel(
pillHost,
lg,
() => M.model.traceBack(lg, port),
() => M.model.traceForward(lg, port),
pick.box,
pick.port,
{ pick: (id) => choose({ box: id }) },
{ values, logics },
);
}
let checks: string[] = [];
try {
checks = M.ev.checkLogic(lg, values);
} catch {
checks = [];
}
drawWarnings(detail, lg, pick, [...checks, ...(result?.errors ?? [])]);
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";
@@ -497,23 +485,34 @@ function openDataFlow(): void {
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 = M.ev.breaks(lg, values, { logics });
} catch {
broken = result.breaks ?? [];
}
unsaved.hidden = lg === savedLg;
undo.disabled = !stack.length;
deleteButton.hidden = !lg.key;
const selected =
pick?.kind === "box" || pick?.kind === "link" ? pick.id : (pick?.box ?? null);
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
M.diagram.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,
});
drawPanel();
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
// ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠)
(M.summary.renderSummary as (...args: unknown[]) => void)(
M.summary.renderSummary(
summaryHost,
lg,
result,
{ change: edit },
{ change: edit, select: choose },
{ values },
);
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
};
const save = async (): Promise<void> => {
@@ -524,7 +523,7 @@ function openDataFlow(): void {
}
let problems: string[] = [];
try {
problems = M.ev.checkLogic(lg, values);
problems = M.ev.checkLogic(lg, values, { logics });
} catch {
problems = [];
}
@@ -588,6 +587,18 @@ function openDataFlow(): void {
el("div", {
className: "m02-df__actions",
children: [
createButton({
label: te("Standards"),
variant: "ghost",
size: "sm",
onClick: () =>
openModal({
title: te("Standards"),
closeLabel: te("Close"),
mount: (host: HTMLElement) =>
M.std.renderStandards(host, { change: () => redraw() }),
}),
}),
undo,
createButton({ label: te("Save"), size: "sm", onClick: () => void save() }),
deleteButton,
@@ -2,12 +2,15 @@
* M02_DF_Entry_Detail.ts
* 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름)
* · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움.
* 선 · 알약 본문은 규칙 판 · 알약 판(sub2)이 오기 전까지 이 파일이 흐름 줄(보내는 곳 → 받는 곳 · 출처 사슬 · 가는 곳)만 보임.
* 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import type { CfLogic, CfPort, CfStep } from "./M02_DF_Model";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfAnswer, CfQuery } from "./M02_DF_Api";
import type { CfBreak, CfValues } from "./M02_DF_Eval";
import type { CfLogic } from "./M02_DF_Model";
const TEXT = {
Detail: ["상세 내용", "Details"],
@@ -20,14 +23,6 @@ const TEXT = {
"Choose a box, pill or line in the diagram",
],
Line: ["선", "Line"],
From: ["보내는 곳", "From"],
To: ["받는 곳", "To"],
Rule: ["선 규칙", "Line rule"],
RuleNone: ["없음 — 값을 그대로 보냄", "None — sends the value as is"],
Back: ["온 길(출처)", "Came from"],
Forward: ["가는 길", "Goes to"],
NoTrace: ["아직 따라갈 수 없음", "Can't trace yet"],
Nowhere: ["이어진 곳 없음", "Not connected"],
In: ["받는 알약", "Incoming pill"],
Out: ["내보내는 알약", "Outgoing pill"],
} as const;
@@ -39,7 +34,7 @@ const tt = (key: keyof typeof TEXT): string =>
export type DfPick =
| { kind: "box"; id: string }
| { kind: "link"; id: string }
| { kind: "pill"; box: string; name: string; dir: "in" | "out" }
| { kind: "pill"; box: string; port: string; dir: "in" | "out" }
| null;
export interface DetailParts {
@@ -91,7 +86,6 @@ export function detailFrame(): DetailParts {
}
const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
const portText = (lg: CfLogic, p: CfPort): string => `${boxName(lg, p.box)} · ${p.name}`;
/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */
export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
@@ -107,77 +101,113 @@ export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}`
: "";
d.pill.textContent =
pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.name}` : "";
pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : "";
d.pill.hidden = pick?.kind !== "pill";
}
/** 경고 — 화면 계산 · 검사 글 가운데 고른 것(상자 이름 · 알약 이름)이 든 줄만 */
export function drawWarnings(d: DetailParts, lg: CfLogic, pick: DfPick, all: string[]): void {
const names: string[] = [];
if (pick?.kind === "box") names.push(`「${boxName(lg, pick.id)}」`);
if (pick?.kind === "pill") names.push(`「${boxName(lg, pick.box)}」`);
if (pick?.kind === "link") {
const l = lg.links.find((x) => x.id === pick.id);
if (l) names.push(`「${boxName(lg, l.from.box)}」`, `「${boxName(lg, l.to.box)}」`);
}
const mine = [...new Set(all)].filter(
(w) => names.some((n) => w.includes(n)) && (pick?.kind !== "pill" || w.includes(pick.name)),
/** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */
export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void {
const mine = all.filter((k) =>
pick?.kind === "box"
? k.box === pick.id
: pick?.kind === "pill"
? k.box === pick.box && k.port === pick.port && !k.link
: pick?.kind === "link"
? k.link === pick.id
: false,
);
d.warn.replaceChildren(
...(mine.length
? mine.map((w) => el("li", { text: w }))
? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w }))
: [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]),
);
}
/** 선 본문 — 보내는 곳 → 받는 곳 · 선 규칙 글(규칙 판이 오면 그쪽이 그림) */
export function lineBody(
host: HTMLElement,
/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */
export async function loadLogics(
lg: CfLogic,
id: string,
ruleText: string | null,
): void {
const l = lg.links.find((x) => x.id === id);
if (!l) return void host.replaceChildren();
host.replaceChildren(
el("dl", {
className: "m02-df__detail-dl",
children: [
el("dt", { text: tt("From") }),
el("dd", { text: portText(lg, l.from) }),
el("dt", { text: tt("To") }),
el("dd", { text: portText(lg, l.to) }),
el("dt", { text: tt("Rule") }),
el("dd", { text: ruleText ?? tt("RuleNone") }),
],
}),
);
get: (key: string) => Promise<CfLogic | null>,
): Promise<{ logics: Record<string, CfLogic>; errors: string[] }> {
const logics: Record<string, CfLogic> = {};
const errors: string[] = [];
const keysOf = (x: CfLogic): string[] =>
x.boxes.flatMap((b) =>
b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [],
);
const seen = new Set<string>(lg.key ? [lg.key] : []);
let todo = keysOf(lg);
while (todo.length) {
const key = todo.shift()!;
if (seen.has(key)) continue;
seen.add(key);
try {
const got = await get(key);
if (!got) throw new Error(key);
logics[key] = got;
todo = [...todo, ...keysOf(got)];
} catch (e) {
errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`);
}
}
return { logics, errors };
}
/** 알약 본문 — 온 길(출처 사슬) · 가는 길 · 흐름 따라가기(Model traceBack · traceForward)가 아직이면 한 줄 */
export function pillBody(
host: HTMLElement,
lg: CfLogic,
back: () => CfStep[],
forward: () => CfStep[],
): void {
const list = (steps: () => CfStep[]): HTMLElement => {
let got: CfStep[];
try {
got = steps();
} catch {
return el("p", { className: "m02-df__muted", text: tt("NoTrace") });
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
export async function unitChoices(): Promise<string[]> {
const count = new Map<string, number>();
try {
for (const x of await fetchLogics()) {
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
}
if (!got.length) return el("p", { className: "m02-df__muted", text: tt("Nowhere") });
return el("ol", {
className: "m02-df__detail-trace",
children: got.map((s) => el("li", { text: portText(lg, { box: s.box, name: s.port }) })),
});
};
host.replaceChildren(
el("h5", { text: tt("Back") }),
list(back),
el("h5", { text: tt("Forward") }),
list(forward),
} catch {
/* 못 받으면 기본 하나 */
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
}
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
export function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const els = inp.elements
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
.map((e) => {
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
if (field) q.field = field;
return { q, box: b.id, port: e.name };
});
const table = inp.table
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
: [];
return [...els, ...table];
});
}
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
export async function fetchValues(
ask: (qs: CfQuery[], els: never) => Promise<CfAnswer[]>,
lg: CfLogic,
): Promise<{ values: CfValues; errors: string[] }> {
const asks = queriesOf(lg);
if (!asks.length) return { values: {}, errors: [] };
const answers = await ask(
asks.map((a) => a.q),
lg.userElements as never,
);
const values: CfValues = {};
const errors: string[] = [];
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
}
return { values, errors };
}