- 변환: 설계 값 변수 예시값 = 줄 시험입력 · 없으면 check_master.시험입력 기본(글 수 → 수) · totalRound = null(옛 계는 끝수 없음) - Model CfLogic.totalRound(없음 = 기본 계금 1원 미만 버림 · null = 끝수 없음) · Round totalRoundFor · roundBy/roundText 가 null 받음 · Summary · M01 흐름 보기 합계가 그것을 씀 - Compute: 함수 결과 오류는 오류 값(「#N/A」 등)으로 넘김(화면 Eval 과 같음 · 받는 칸으로 번짐 · 옛 엔진은 그 줄을 비움) — 서버가 빈 칸 0 으로 계속 세던 차이 없앰 - 화면 값 물음(Entry_Detail queriesOf): 인력 · 기계 · 유가 조건 찾기도 자리 물음 · 재료 검색어 틀은 채운 글 · 다른 로직 입력은 서버 /logic-value 로 받은 설계 값째 셈(그 안 조건 찾기 · 단가까지) · Eval 은 서버 출력이 있으면 그것 - Values.one_value: 인력 · 기계 · 유가 자리 물음 = group_find - 전수 대조 시험입력 1,414/1,472(96.1%) · 기본 입력 1,429(97.1%) — 남은 49 는 sub4 범위규칙 칸 · 검색어 찾기 · 재료 값 차이 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M02_DF_Entry_Detail.ts
|
|
* 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름)
|
|
* · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움.
|
|
* 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보.
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import { el } from "@ui/ui_template_elements";
|
|
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 { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model";
|
|
|
|
const TEXT = {
|
|
Detail: ["상세 내용", "Details"],
|
|
Settings: ["설정", "Settings"],
|
|
Body: ["본문", "Content"],
|
|
Warn: ["경고", "Warnings"],
|
|
WarnNone: ["경고 없음", "No warnings"],
|
|
PickSome: [
|
|
"다이어그램에서 상자 · 알약 · 선을 고르면 여기에 그 상세 내용이 보임",
|
|
"Choose a box, pill or line in the diagram",
|
|
],
|
|
Line: ["선", "Line"],
|
|
In: ["받는 알약", "Incoming pill"],
|
|
Out: ["내보내는 알약", "Outgoing pill"],
|
|
} as const;
|
|
|
|
const tt = (key: keyof typeof TEXT): string =>
|
|
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
|
|
|
|
/** 고른 것 — 상자 · 선 · 알약 */
|
|
export type DfPick =
|
|
| { kind: "box"; id: string }
|
|
| { kind: "link"; id: string }
|
|
| { kind: "pill"; box: string; port: string; dir: "in" | "out" }
|
|
| null;
|
|
|
|
export interface DetailParts {
|
|
root: HTMLElement;
|
|
title: HTMLElement;
|
|
pill: HTMLElement;
|
|
hint: HTMLElement;
|
|
settings: HTMLElement;
|
|
body: HTMLElement;
|
|
warn: HTMLElement;
|
|
}
|
|
|
|
const row = (label: string, host: HTMLElement): HTMLElement =>
|
|
el("div", {
|
|
className: "m02-df__detail-row",
|
|
children: [el("h4", { text: label }), host],
|
|
});
|
|
|
|
/** 상세 내용 칸 — 머리 + 설정 · 본문 · 경고 */
|
|
export function detailFrame(): DetailParts {
|
|
const title = el("strong", { className: "m02-df__detail-title" });
|
|
const pill = el("span", { className: "m02-df__detail-pill" });
|
|
const hint = el("p", { className: "m02-df__muted", text: tt("PickSome") });
|
|
const settings = el("div", { className: "m02-df__detail-settings" });
|
|
const body = el("div", { className: "m02-df__detail-body" });
|
|
const warn = el("ul", { className: "m02-df__detail-warn" });
|
|
const root = el("section", {
|
|
className: "m02-df__part m02-df__detail",
|
|
children: [
|
|
el("div", {
|
|
className: "m02-df__detail-head",
|
|
children: [
|
|
el("h3", { text: tt("Detail") }),
|
|
el("div", { className: "m02-df__detail-name", children: [title, pill] }),
|
|
],
|
|
}),
|
|
el("div", {
|
|
className: "m02-df__part-body",
|
|
children: [
|
|
hint,
|
|
row(tt("Settings"), settings),
|
|
row(tt("Body"), body),
|
|
row(tt("Warn"), warn),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
return { root, title, pill, hint, settings, body, warn };
|
|
}
|
|
|
|
const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
|
|
|
|
/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */
|
|
export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
|
|
d.hint.hidden = !!pick;
|
|
for (const r of [d.settings, d.body, d.warn]) (r.parentElement as HTMLElement).hidden = !pick;
|
|
const link = pick?.kind === "link" ? lg.links.find((l) => l.id === pick.id) : undefined;
|
|
d.title.textContent =
|
|
pick?.kind === "box"
|
|
? boxName(lg, pick.id)
|
|
: pick?.kind === "pill"
|
|
? boxName(lg, pick.box)
|
|
: link
|
|
? `${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.port}` : "";
|
|
d.pill.hidden = pick?.kind !== "pill";
|
|
}
|
|
|
|
/** 경고 — 끊긴 곳(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
|
|
? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w }))
|
|
: [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]),
|
|
);
|
|
}
|
|
|
|
/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */
|
|
export async function loadLogics(
|
|
lg: CfLogic,
|
|
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 };
|
|
}
|
|
|
|
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
|
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);
|
|
}
|
|
} 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 : ["원/㎡"];
|
|
}
|
|
|
|
/** 재료 검색어 틀(「레미콘 {a}-{b}」)의 자리에 알약 값 — 빈 자리가 있으면 null(서버 Compute `_fill_template` 과 같음) */
|
|
function fillTemplate(
|
|
rule: NonNullable<CfElementRef["rule"]>,
|
|
pill: (p: CfPort) => unknown,
|
|
): unknown {
|
|
const got = new Map((rule.slots ?? []).map((x) => [x.name, pill(x.from)]));
|
|
if ([...got.values()].some((v) => v === null || v === undefined)) return null;
|
|
return (rule.template ?? "").replace(/\{([^{}]+)\}/g, (m, k: string) =>
|
|
got.has(k) ? String(got.get(k)) : m,
|
|
);
|
|
}
|
|
|
|
/** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */
|
|
const RULE_GROUPS = ["인력", "기계", "유가전력"];
|
|
|
|
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을
|
|
* pill 로 읽어 넘김 · 인력 · 기계 · 유가 조건 찾기도) · 표(알약 「표」) · 다른 로직(서버가 받은 설계 값으로 셈 · port 「*」)
|
|
* · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
|
export function queriesOf(
|
|
lg: CfLogic,
|
|
pill: (p: CfPort) => unknown = () => null,
|
|
elementPort: (e: CfElementRef) => string = (e) => e.name,
|
|
): { q: CfQuery; box: string; port: string }[] {
|
|
return lg.boxes.flatMap((b) => {
|
|
const inp = b.input;
|
|
if (b.kind === "input" && inp?.mode === "logic" && inp.logic?.key) {
|
|
const ref = inp.logic;
|
|
const designs: Record<string, unknown> = {};
|
|
for (const d of ref.designs) {
|
|
const l = lg.links.find((x) => x.to.box === b.id && x.to.name === d.name);
|
|
const v = l ? pill(l.from) : ref.examples?.[d.name];
|
|
if (v !== null && v !== undefined) designs[d.name] = v;
|
|
}
|
|
const q: CfQuery = { id: `${b.id}*`, group: "기계", logic: { key: ref.key, designs } };
|
|
return [{ q, box: b.id, port: "*" }];
|
|
}
|
|
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
|
|
const group = inp.group;
|
|
const slots = inp.elements
|
|
.filter(
|
|
(e) =>
|
|
(group === "재료" && e.mode && e.mode !== "fixed") ||
|
|
(RULE_GROUPS.includes(group) && e.mode === "rule"),
|
|
)
|
|
.map((e) => {
|
|
const port = elementPort(e);
|
|
const q: CfQuery = {
|
|
id: `${b.id}\u0001${port}`,
|
|
group,
|
|
unit: e.unit,
|
|
slot: { ...e, field: e.rule?.field, op: e.rule?.op },
|
|
};
|
|
if (e.mode === "rule" && e.rule)
|
|
q.value = e.rule.field === "검색어" ? fillTemplate(e.rule, pill) : pill(e.rule.from);
|
|
return { q, box: b.id, port };
|
|
});
|
|
const els = inp.elements
|
|
.filter((e) => e.ref && !e.ref.startsWith("UE-") && !slots.some((s) => s.port === e.name))
|
|
.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 [...slots, ...els, ...table];
|
|
});
|
|
}
|
|
|
|
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
|
|
export async function fetchValues(
|
|
ask: (qs: CfQuery[], els: never) => Promise<CfAnswer[]>,
|
|
lg: CfLogic,
|
|
pill?: (p: CfPort) => unknown,
|
|
elementPort?: (e: CfElementRef) => string,
|
|
): Promise<{ values: CfValues; errors: string[] }> {
|
|
const all = queriesOf(lg, pill, elementPort);
|
|
const asks = all.filter((a) => !a.q.logic);
|
|
const values: CfValues = {};
|
|
const errors: string[] = [];
|
|
// 다른 로직 — 서버가 받은 설계 값으로 셈(그 안 조건 찾기 · 단가까지) → 출력 이름마다 값
|
|
const logicAsks = all.filter((a) => a.q.logic);
|
|
const logicDone = logicAsks.length
|
|
? import("./M02_DF_Api").then(({ cfLogicValue }) =>
|
|
Promise.all(
|
|
logicAsks.map((a) =>
|
|
cfLogicValue(a.q.logic!.key, a.q.logic!.designs).then(
|
|
(got) => {
|
|
values[a.box] = { ...(values[a.box] ?? {}), ...(got.outputs as CfValues[string]) };
|
|
},
|
|
(e: unknown) => errors.push(`${a.q.logic!.key} — ${String(e)}`),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: Promise.resolve();
|
|
if (!asks.length) {
|
|
await logicDone;
|
|
return { values, errors };
|
|
}
|
|
const answers = await ask(
|
|
asks.map((a) => a.q),
|
|
lg.userElements as never,
|
|
);
|
|
await logicDone;
|
|
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 };
|
|
}
|