Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -41,6 +41,8 @@ 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 "./M01_MasterData_UI_Logic_Style.css";
|
||||
|
||||
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
|
||||
@@ -447,6 +449,8 @@ export async function mountM01LogicLab(
|
||||
trialMenu([
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createCostFlowButton(),
|
||||
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createDataFlowButton(),
|
||||
]),
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
discardButton,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Detail.ts
|
||||
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음
|
||||
* 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval";
|
||||
import {
|
||||
portsIn,
|
||||
portsOut,
|
||||
traceBack,
|
||||
traceForward,
|
||||
type CfBox,
|
||||
type CfGateOp,
|
||||
type CfLink,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
type CfRound,
|
||||
type CfRule,
|
||||
type CfStep,
|
||||
} from "./M02_DF_Model";
|
||||
import { applyRule, ruleLabel } from "./M02_DF_Rule";
|
||||
|
||||
const TEXT = {
|
||||
Table: ["표 {n}줄", "table, {n} rows"],
|
||||
Empty: ["값 없음", "no value"],
|
||||
LabelLookup: ["표에서 「{col}」 찾기", "Look up “{col}” in the table"],
|
||||
LabelGate: ["「{pill}」 {op} {value} 일 때만 보냄", "Send only when “{pill}” {op} {value}"],
|
||||
LabelScale: ["값 {op} {value}", "value {op} {value}"],
|
||||
LabelRound: ["소수 {places}자리 {method}", "{places} decimals, {method}"],
|
||||
NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"],
|
||||
Blocked: ["통과 안 됨", "does not pass"],
|
||||
NoRowLater: ["표에서 맞는 줄은 계산 뒤에 보임", "The matching row shows after calculation"],
|
||||
EmptyIn: ["빈 받는 자리 — 선이 없음", "Empty receiving spot — no line"],
|
||||
NoCell: ["받은 알약을 칸에 놓지 않음", "The received pill is not placed in a cell"],
|
||||
Dead: ["아직 어디로도 안 감", "Goes nowhere yet"],
|
||||
NoValue: ["값이 아직 없음", "No value yet"],
|
||||
} as const;
|
||||
|
||||
export const td = (key: keyof typeof TEXT, fill: Record<string, string | number> = {}): string =>
|
||||
Object.entries(fill).reduce(
|
||||
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
|
||||
);
|
||||
|
||||
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
|
||||
|
||||
export const boxOf = (lg: CfLogic, id: string): CfBox | undefined =>
|
||||
lg.boxes.find((b) => b.id === id);
|
||||
|
||||
export const portText = (lg: CfLogic, p: CfPort): string =>
|
||||
`${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`;
|
||||
|
||||
/** 계산 한 번 — 못 하면 null */
|
||||
export function evalSafe(lg: CfLogic, values?: CfValues): CfEvalResult | null {
|
||||
try {
|
||||
return evalLogic(lg, values);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined =>
|
||||
ev?.ports[p.box]?.[p.name];
|
||||
|
||||
/** 화면에 보일 값 글 — 표는 줄 수 */
|
||||
export function showValue(v: CfValue | undefined): string {
|
||||
if (v === undefined || v === null) return td("Empty");
|
||||
if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) });
|
||||
return typeof v === "number" ? String(Number(v.toPrecision(12))) : String(v);
|
||||
}
|
||||
|
||||
export function unitOf(lg: CfLogic, p: CfPort): string {
|
||||
const box = boxOf(lg, p.box);
|
||||
return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-";
|
||||
}
|
||||
|
||||
/** 알약이 나가는(out) 쪽인가 */
|
||||
export const isOut = (box: CfBox, name: string): boolean =>
|
||||
portsOut(box).some((p) => p.name === name);
|
||||
|
||||
/* ── 규칙 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) && v !== "" && v !== null ? n : null;
|
||||
};
|
||||
|
||||
/** 조건 통과 — 숫자면 숫자끼리 · 아니면 글끼리 */
|
||||
export function gatePass(op: CfGateOp, v: unknown, value: number | string): boolean {
|
||||
const a = num(v);
|
||||
const b = num(value);
|
||||
const [x, y] = a !== null && b !== null ? [a, b] : [String(v ?? ""), String(value)];
|
||||
switch (op) {
|
||||
case "=":
|
||||
return x === y;
|
||||
case "≠":
|
||||
return x !== y;
|
||||
case "≥":
|
||||
return x >= y;
|
||||
case "≤":
|
||||
return x <= y;
|
||||
case ">":
|
||||
return x > y;
|
||||
default:
|
||||
return x < y;
|
||||
}
|
||||
}
|
||||
|
||||
export function roundValue(r: CfRound, v: number): number {
|
||||
const k = 10 ** r.places;
|
||||
const f = r.method === "올림" ? Math.ceil : r.method === "버림" ? Math.floor : Math.round;
|
||||
return f(v * k) / k;
|
||||
}
|
||||
|
||||
/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */
|
||||
export function labelOf(lg: CfLogic, rule: CfRule | undefined): string {
|
||||
if (!rule) return td("NoRule");
|
||||
try {
|
||||
return ruleLabel(rule);
|
||||
} catch {
|
||||
if (rule.kind === "lookup") return td("LabelLookup", { col: rule.col });
|
||||
if (rule.kind === "gate")
|
||||
return td("LabelGate", { pill: portText(lg, rule.from), op: rule.op, value: rule.value });
|
||||
if (rule.kind === "scale") return td("LabelScale", { op: rule.op, value: rule.value });
|
||||
return td("LabelRound", { places: rule.places, method: rule.method });
|
||||
}
|
||||
}
|
||||
|
||||
/** 지나가기 전 값 → 지난 뒤 값 미리 보기 */
|
||||
export function preview(
|
||||
link: CfLink,
|
||||
rule: CfRule | undefined,
|
||||
ev: CfEvalResult | null,
|
||||
): { before: string; after: string } {
|
||||
const raw = valueOf(ev, link.from);
|
||||
const before = showValue(raw);
|
||||
if (!rule) return { before, after: before };
|
||||
const conds: Record<string, unknown> = {};
|
||||
if (rule.kind === "lookup") for (const c of rule.conds) conds[c.col] = valueOf(ev, c.from);
|
||||
try {
|
||||
const r = applyRule(rule, raw, conds);
|
||||
return {
|
||||
before,
|
||||
after: r.value === null ? (r.why ?? td("Empty")) : showValue(r.value as CfValue),
|
||||
};
|
||||
} catch {
|
||||
const n = num(raw);
|
||||
if (rule.kind === "gate")
|
||||
return {
|
||||
before,
|
||||
after: gatePass(rule.op, valueOf(ev, rule.from), rule.value) ? before : td("Blocked"),
|
||||
};
|
||||
if (rule.kind === "lookup" || n === null) return { before, after: td("NoRowLater") };
|
||||
if (rule.kind === "round") return { before, after: showValue(roundValue(rule, n)) };
|
||||
const v = rule.value;
|
||||
const out =
|
||||
rule.op === "×"
|
||||
? n * v
|
||||
: rule.op === "÷"
|
||||
? v
|
||||
? n / v
|
||||
: NaN
|
||||
: rule.op === "+%"
|
||||
? n * (1 + v / 100)
|
||||
: n * (1 - v / 100);
|
||||
return { before, after: Number.isFinite(out) ? showValue(out) : td("Empty") };
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 알약에서 나간 조건 선 — 지금 값으로 통과하는 수 */
|
||||
export function gateReport(
|
||||
lg: CfLogic,
|
||||
link: CfLink,
|
||||
ev: CfEvalResult | null,
|
||||
): { total: number; pass: string[] } {
|
||||
const same = lg.links.filter(
|
||||
(l) =>
|
||||
l.from.box === link.from.box && l.from.name === link.from.name && l.rule?.kind === "gate",
|
||||
);
|
||||
const pass = same
|
||||
.filter((l) => {
|
||||
const r = l.rule as Extract<CfRule, { kind: "gate" }>;
|
||||
return gatePass(r.op, valueOf(ev, r.from), r.value);
|
||||
})
|
||||
.map((l) => l.id);
|
||||
return { total: same.length, pass };
|
||||
}
|
||||
|
||||
/* ── 흐름 · 끊김 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 거슬러 오르는 길(dir "in" = 온 길) · 따라가는 길(dir "out") — 모델 것, 없으면 작은 셈 */
|
||||
export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] {
|
||||
try {
|
||||
return dir === "in" ? traceBack(lg, p) : traceForward(lg, p);
|
||||
} catch {
|
||||
const out: CfStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
const walk = (port: CfPort, back: boolean): void => {
|
||||
const key = `${port.box}/${port.name}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const box = boxOf(lg, port.box);
|
||||
if (!box) return;
|
||||
if (back) {
|
||||
const into = lg.links.filter(
|
||||
(l) => l.to.box === port.box && (isOut(box, port.name) || l.to.name === port.name),
|
||||
);
|
||||
for (const l of into) {
|
||||
out.push({ box: l.from.box, port: l.from.name, dir: "out", link: l.id, rule: l.rule });
|
||||
walk(l.from, true);
|
||||
}
|
||||
} else {
|
||||
const outs = lg.links.filter((l) => l.from.box === port.box && l.from.name === port.name);
|
||||
for (const l of outs) {
|
||||
out.push({ box: l.to.box, port: l.to.name, dir: "in", link: l.id, rule: l.rule });
|
||||
const next = boxOf(lg, l.to.box);
|
||||
for (const o of next ? portsOut(next) : []) walk({ box: next!.id, name: o.name }, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(p, dir === "in");
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
|
||||
export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] {
|
||||
try {
|
||||
return breaks(lg)
|
||||
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
|
||||
.map((b) => b.text);
|
||||
} catch {
|
||||
// 모델의 끊긴 곳 목록이 준비되기 전 — 같은 뜻의 작은 셈
|
||||
}
|
||||
const box = boxOf(lg, boxId);
|
||||
if (!box) return [];
|
||||
const out: string[] = [];
|
||||
for (const p of portsIn(box)) {
|
||||
if (name && p.name !== name) continue;
|
||||
if (!lg.links.some((l) => l.to.box === boxId && l.to.name === p.name)) out.push(td("EmptyIn"));
|
||||
else if (box.func?.inputs.some((i) => i.port === p.name && !i.cell)) out.push(td("NoCell"));
|
||||
}
|
||||
for (const p of portsOut(box)) {
|
||||
if (name && p.name !== name) continue;
|
||||
if (!lg.links.some((l) => l.from.box === boxId && l.from.name === p.name)) out.push(td("Dead"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const noValue = (): string => td("NoValue");
|
||||
@@ -67,12 +67,6 @@
|
||||
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
|
||||
}
|
||||
|
||||
.m02-df__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 시작 화면 */
|
||||
.m02-df__start {
|
||||
display: grid;
|
||||
@@ -218,3 +212,72 @@
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 상세 내용 — 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약 이름) · 설정 · 본문 · 경고 */
|
||||
.m02-df__detail-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df__detail-head h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-df__detail-pill {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) 0;
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df__detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-row h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__detail-warn {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__detail-dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-dl dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-trace {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Entry.ts
|
||||
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
|
||||
* 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨
|
||||
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게.
|
||||
* 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary
|
||||
* → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S.
|
||||
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
|
||||
* 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 —
|
||||
* M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 ·
|
||||
* 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -23,6 +23,14 @@ 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 {
|
||||
detailFrame,
|
||||
drawHead,
|
||||
drawWarnings,
|
||||
lineBody,
|
||||
pillBody,
|
||||
type DfPick,
|
||||
} from "./M02_DF_Entry_Detail";
|
||||
import "./M02_DF_Entry.css";
|
||||
|
||||
/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
|
||||
@@ -37,7 +45,8 @@ const loadMods = () =>
|
||||
import("./M02_DF_Summary"),
|
||||
import("./M02_DF_Sample"),
|
||||
import("./M02_DF_InputPanel"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({
|
||||
import("./M02_DF_Rule"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample, input, rule]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
@@ -47,12 +56,13 @@ const loadMods = () =>
|
||||
summary,
|
||||
sample,
|
||||
input,
|
||||
rule,
|
||||
}));
|
||||
type Mods = Awaited<ReturnType<typeof loadMods>>;
|
||||
|
||||
const TEXT = {
|
||||
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
|
||||
Title: ["단가산출 다이어그램", "Cost diagram"],
|
||||
Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"],
|
||||
Title: ["단가산출 흐름도", "Cost flow"],
|
||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
@@ -69,8 +79,6 @@ const TEXT = {
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Diagram: ["다이어그램", "Diagram"],
|
||||
Box: ["고른 상자", "Chosen box"],
|
||||
PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"],
|
||||
Summary: ["원가", "Cost"],
|
||||
Color: ["상자 색", "Box colour"],
|
||||
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
|
||||
@@ -164,7 +172,7 @@ async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; er
|
||||
|
||||
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
|
||||
export function createDataFlowButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openDataFlow });
|
||||
return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow });
|
||||
}
|
||||
|
||||
function openDataFlow(): void {
|
||||
@@ -181,6 +189,7 @@ function openDataFlow(): void {
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) paper ? void showStart() : shut();
|
||||
@@ -317,7 +326,8 @@ function openDataFlow(): void {
|
||||
let lg = first;
|
||||
let savedLg: CfLogic | null = saved ? first : null;
|
||||
let stack: CfLogic[] = [];
|
||||
let selected: string | null = null;
|
||||
let pick: DfPick = null;
|
||||
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
|
||||
let values: CfValues = {};
|
||||
let valueSig = "";
|
||||
let notes: string[] = [];
|
||||
@@ -327,14 +337,14 @@ function openDataFlow(): void {
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
|
||||
const diagramHost = el("div");
|
||||
const pickHint = muted(te("PickBox"));
|
||||
// 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나
|
||||
const colorBox = el("input", {
|
||||
className: "m02-df__color-input",
|
||||
attrs: { type: "color", "aria-label": te("Color") },
|
||||
});
|
||||
colorBox.addEventListener("change", () => {
|
||||
if (selected) edit(M.model.setColor(lg, selected, colorBox.value));
|
||||
const id = pickedBox();
|
||||
if (id) edit(M.model.setColor(lg, id, colorBox.value));
|
||||
});
|
||||
const colorRow = el("label", {
|
||||
className: "m02-df__color",
|
||||
@@ -345,6 +355,11 @@ function openDataFlow(): void {
|
||||
const outHost = el("div");
|
||||
const inHost = el("div");
|
||||
const summaryHost = el("div");
|
||||
const lineHost = el("div");
|
||||
const pillHost = el("div");
|
||||
const detail = detailFrame();
|
||||
detail.settings.append(colorRow);
|
||||
detail.body.append(inHost, funcHost, outHost, lineHost, pillHost);
|
||||
|
||||
const evalNow = (): CfEvalResult => {
|
||||
try {
|
||||
@@ -403,7 +418,16 @@ function openDataFlow(): void {
|
||||
|
||||
const diagramOn = {
|
||||
select: (id: string | null): void => {
|
||||
selected = id;
|
||||
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();
|
||||
},
|
||||
move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)),
|
||||
@@ -416,19 +440,50 @@ function openDataFlow(): void {
|
||||
unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)),
|
||||
add: (kind: CfKind, x: number, y: number): void => {
|
||||
const got = M.model.addBox(lg, kind, x, y);
|
||||
selected = got.box.id;
|
||||
pick = { kind: "box", id: got.box.id };
|
||||
editFromDiagram(got.lg);
|
||||
},
|
||||
remove: (id: string): void => {
|
||||
if (selected === id) selected = null;
|
||||
if (pickedBox() === id) pick = null;
|
||||
editFromDiagram(M.model.removeBox(lg, id));
|
||||
},
|
||||
};
|
||||
|
||||
const drawPanel = (): void => {
|
||||
const box = lg.boxes.find((b) => b.id === selected);
|
||||
pickHint.hidden = !!box;
|
||||
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);
|
||||
colorRow.hidden = !box;
|
||||
lineHost.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(
|
||||
pillHost,
|
||||
lg,
|
||||
() => M.model.traceBack(lg, port),
|
||||
() => M.model.traceForward(lg, port),
|
||||
);
|
||||
}
|
||||
let checks: string[] = [];
|
||||
try {
|
||||
checks = M.ev.checkLogic(lg, values);
|
||||
} catch {
|
||||
checks = [];
|
||||
}
|
||||
drawWarnings(detail, lg, pick, [...checks, ...(result?.errors ?? [])]);
|
||||
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
|
||||
funcHost.hidden = box?.kind !== "function";
|
||||
outHost.hidden = box?.kind !== "output";
|
||||
@@ -445,6 +500,8 @@ function openDataFlow(): void {
|
||||
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 });
|
||||
drawPanel();
|
||||
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
|
||||
@@ -517,10 +574,11 @@ function openDataFlow(): void {
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo });
|
||||
const deleteButton = createButton({
|
||||
label: te("Delete"),
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => void doDelete(),
|
||||
});
|
||||
strip.replaceChildren(
|
||||
@@ -531,7 +589,7 @@ function openDataFlow(): void {
|
||||
className: "m02-df__actions",
|
||||
children: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: () => void save() }),
|
||||
createButton({ label: te("Save"), size: "sm", onClick: () => void save() }),
|
||||
deleteButton,
|
||||
],
|
||||
}),
|
||||
@@ -544,10 +602,7 @@ function openDataFlow(): void {
|
||||
part(te("Diagram"), "m02-df__diagram", [diagramHost]),
|
||||
el("div", {
|
||||
className: "m02-df__lower",
|
||||
children: [
|
||||
part(te("Box"), "m02-df__box", [pickHint, colorRow, inHost, funcHost, outHost]),
|
||||
part(te("Summary"), "m02-df__summary", [summaryHost]),
|
||||
],
|
||||
children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -573,7 +628,5 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m02-df__big");
|
||||
return button;
|
||||
return createButton({ label, variant: "filled", size: "sm", onClick });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/* =============================================================================
|
||||
* 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";
|
||||
|
||||
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"],
|
||||
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;
|
||||
|
||||
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; name: 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;
|
||||
const portText = (lg: CfLogic, p: CfPort): string => `${boxName(lg, p.box)} · ${p.name}`;
|
||||
|
||||
/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 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.name}` : "";
|
||||
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)),
|
||||
);
|
||||
d.warn.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map((w) => el("li", { text: w }))
|
||||
: [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 선 본문 — 보내는 곳 → 받는 곳 · 선 규칙 글(규칙 판이 오면 그쪽이 그림) */
|
||||
export function lineBody(
|
||||
host: HTMLElement,
|
||||
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") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 알약 본문 — 온 길(출처 사슬) · 가는 길 · 흐름 따라가기(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") });
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Eval.ts
|
||||
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
|
||||
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
|
||||
* 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈
|
||||
* 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`)
|
||||
* 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 ·
|
||||
* 조건 겹침 · 조건 안 맞음 · 오류) · DOM 없음
|
||||
* 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -9,19 +11,50 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_DF_Model";
|
||||
import {
|
||||
OUTPUT_PORT,
|
||||
depends,
|
||||
designInputs,
|
||||
elementPort,
|
||||
portsIn,
|
||||
portsOut,
|
||||
ruleSources,
|
||||
type CfBox,
|
||||
type CfLink,
|
||||
type CfLogic,
|
||||
} from "./M02_DF_Model";
|
||||
import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule";
|
||||
|
||||
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
|
||||
export type CfValue = number | string | boolean | null | (number | string | null)[][];
|
||||
|
||||
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
|
||||
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값 · 다른 로직 출력) */
|
||||
export type CfValues = Record<string, Record<string, CfValue>>;
|
||||
|
||||
type Scalar = number | string | boolean | null;
|
||||
|
||||
/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */
|
||||
export interface CfBreak {
|
||||
kind:
|
||||
| "빈 받는 자리"
|
||||
| "통과 안 됨"
|
||||
| "표에 없는 조건"
|
||||
| "맞는 재료 없음"
|
||||
| "조건 겹침"
|
||||
| "조건 안 맞음"
|
||||
| "오류";
|
||||
box: string;
|
||||
port?: string;
|
||||
link?: string;
|
||||
/** 쉬운 말 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CfEvalResult {
|
||||
/** 상자 id → 내보내는 알약 이름 → 값 */
|
||||
ports: Record<string, Record<string, CfValue>>;
|
||||
/** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */
|
||||
arrived?: Record<string, Record<string, CfValue>>;
|
||||
/** 출력 상자 id → 값 */
|
||||
outputs: Record<string, Scalar>;
|
||||
cost: {
|
||||
@@ -34,6 +67,14 @@ export interface CfEvalResult {
|
||||
};
|
||||
/** 쉬운 말 */
|
||||
errors: string[];
|
||||
/** 끊긴 곳(셈 때 생긴 것) */
|
||||
breaks?: CfBreak[];
|
||||
}
|
||||
|
||||
/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */
|
||||
export interface CfEvalOptions {
|
||||
logics?: Record<string, CfLogic>;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
@@ -58,6 +99,40 @@ const TEXT = {
|
||||
"Pill 「{pill}」 of 「{box}」 is not placed in a cell",
|
||||
],
|
||||
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
|
||||
/** {from} {box} {rule} */
|
||||
NotPassed: [
|
||||
"「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음",
|
||||
"Line 「{from}」 → 「{box}」 doesn't pass ({rule})",
|
||||
],
|
||||
NoRow: [
|
||||
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음",
|
||||
"Line 「{from}」 → 「{box}」 — no matching table row",
|
||||
],
|
||||
ManyRows: [
|
||||
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿",
|
||||
"Line 「{from}」 → 「{box}」 — several table rows match",
|
||||
],
|
||||
RuleError: [
|
||||
"「{from}」 → 「{box}」 선 규칙을 셀 수 없음",
|
||||
"Line 「{from}」 → 「{box}」 rule can't be worked out",
|
||||
],
|
||||
/** {box} {pill} */
|
||||
NoMaterial: [
|
||||
"「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음",
|
||||
"「{pill}」 of 「{box}」 — no material matches",
|
||||
],
|
||||
Overlap: [
|
||||
"「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함",
|
||||
"Several condition lines from 「{pill}」 of 「{box}」 pass",
|
||||
],
|
||||
NoneMatch: [
|
||||
"「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음",
|
||||
"No condition line from 「{pill}」 of 「{box}」 passes",
|
||||
],
|
||||
NoLogic: [
|
||||
"「{box}」 이 부르는 로직을 찾을 수 없음",
|
||||
"The logic used by 「{box}」 can't be found",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
@@ -68,23 +143,22 @@ const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =
|
||||
|
||||
/* ── 차례 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */
|
||||
function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } {
|
||||
const funcs = lg.boxes.filter((b) => b.kind === "function");
|
||||
/** 상자 차례 — 기댐(선 · 규칙 조건 · 재료 조건)대로 · 돌고 도는 상자는 loop */
|
||||
function order(lg: CfLogic): { boxes: CfBox[]; loop: CfBox[] } {
|
||||
const deps = depends(lg);
|
||||
const done: CfBox[] = [];
|
||||
const left = new Set(funcs.map((f) => f.id));
|
||||
const left = new Set(lg.boxes.map((b) => b.id));
|
||||
for (let moved = true; moved && left.size;) {
|
||||
moved = false;
|
||||
for (const f of funcs) {
|
||||
if (!left.has(f.id)) continue;
|
||||
const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box));
|
||||
if (waits) continue;
|
||||
done.push(f);
|
||||
left.delete(f.id);
|
||||
for (const b of lg.boxes) {
|
||||
if (!left.has(b.id)) continue;
|
||||
if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue;
|
||||
done.push(b);
|
||||
left.delete(b.id);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) };
|
||||
return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.id)) };
|
||||
}
|
||||
|
||||
/* ── 칸 ─────────────────────────────────────────────────────────────────── */
|
||||
@@ -113,16 +187,18 @@ const fromCalc = (v: CalcValue | undefined): Scalar => {
|
||||
return v.오류;
|
||||
};
|
||||
|
||||
/* ── 계산 ───────────────────────────────────────────────────────────────── */
|
||||
/* ── 상자마다 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
|
||||
function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, CfValue> {
|
||||
const given = values[box.id] ?? {};
|
||||
const pills = portsOut(box);
|
||||
function inputPorts(
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
given: Record<string, CfValue>,
|
||||
): Record<string, CfValue> {
|
||||
const out: Record<string, CfValue> = {};
|
||||
const inp = box.input;
|
||||
if (!inp) return out;
|
||||
// portsOut 차례 = 요소 · 표 · 변수
|
||||
const pills = portsOut(box);
|
||||
const sources: CfValue[] = [
|
||||
...(inp.mode === "group"
|
||||
? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null)
|
||||
@@ -134,16 +210,16 @@ function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, C
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */
|
||||
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 · 출력 끝수 */
|
||||
function runFunc(
|
||||
box: CfBox,
|
||||
incoming: Record<string, CfValue>,
|
||||
arrive: Record<string, CfValue>,
|
||||
given: Record<string, CfValue>,
|
||||
errors: string[],
|
||||
): Scalar {
|
||||
const func = box.func!;
|
||||
const sheet: Sheet = structuredClone(func.sheet);
|
||||
for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null);
|
||||
for (const i of func.inputs) put(sheet.칸, i.cell, arrive[i.port] ?? given[i.port] ?? null);
|
||||
for (const v of func.variables)
|
||||
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
|
||||
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||||
@@ -154,79 +230,203 @@ function runFunc(
|
||||
}
|
||||
const at = func.output.cell.toUpperCase();
|
||||
const c = sheet.칸[at];
|
||||
if (!c) return null;
|
||||
return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
|
||||
const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
|
||||
return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got;
|
||||
}
|
||||
|
||||
/** 화면 계산 */
|
||||
export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult {
|
||||
/* ── 계산 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */
|
||||
export function evalLogic(
|
||||
lg: CfLogic,
|
||||
values: CfValues = {},
|
||||
opts: CfEvalOptions = {},
|
||||
): CfEvalResult {
|
||||
const errors: string[] = [];
|
||||
const found: CfBreak[] = [];
|
||||
const ports: CfEvalResult["ports"] = {};
|
||||
for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values);
|
||||
const into = (box: string): Record<string, CfValue> =>
|
||||
Object.fromEntries(
|
||||
lg.links
|
||||
.filter((l) => l.to.box === box)
|
||||
.map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]),
|
||||
const arrived: NonNullable<CfEvalResult["arrived"]> = {};
|
||||
const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
|
||||
const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null;
|
||||
const passed = new Map<string, boolean>();
|
||||
/** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */
|
||||
const blocked = new Set<string>();
|
||||
/** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */
|
||||
const through = (l: CfLink): CfValue => {
|
||||
const v = valueOf(l.from.box, l.from.name);
|
||||
if (!l.rule) return v;
|
||||
if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null;
|
||||
const conds = Object.fromEntries(
|
||||
ruleSources(l.rule).map((p) => [p.name, valueOf(p.box, p.name)]),
|
||||
);
|
||||
const { funcs, loop } = order(lg);
|
||||
for (const f of loop) errors.push(tl("Loop", { box: f.name }));
|
||||
for (const f of funcs)
|
||||
ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) };
|
||||
const res = applyRule(l.rule, v, conds);
|
||||
if (l.rule.kind === "gate") passed.set(l.id, res.why === null);
|
||||
const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) };
|
||||
if (res.why === "통과 안 됨")
|
||||
found.push({
|
||||
kind: "통과 안 됨",
|
||||
box: l.to.box,
|
||||
port: l.to.name,
|
||||
link: l.id,
|
||||
text: tl("NotPassed", fill),
|
||||
});
|
||||
else if (res.why) {
|
||||
const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류";
|
||||
const key =
|
||||
res.why === "표에 없는 조건"
|
||||
? "NoRow"
|
||||
: res.why === "표에 여럿 맞음"
|
||||
? "ManyRows"
|
||||
: "RuleError";
|
||||
const text = tl(key, fill);
|
||||
found.push({ kind, box: l.to.box, port: l.to.name, link: l.id, text });
|
||||
errors.push(text);
|
||||
}
|
||||
if (res.why) blocked.add(l.to.box);
|
||||
return res.why ? null : res.value;
|
||||
};
|
||||
const arrive = (box: CfBox): Record<string, CfValue> => {
|
||||
const got = Object.fromEntries(
|
||||
lg.links.filter((l) => l.to.box === box.id).map((l) => [l.to.name, through(l)]),
|
||||
);
|
||||
arrived[box.id] = got;
|
||||
return got;
|
||||
};
|
||||
const { boxes, loop } = order(lg);
|
||||
for (const b of loop) errors.push(tl("Loop", { box: b.name }));
|
||||
const outputs: CfEvalResult["outputs"] = {};
|
||||
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
for (const b of lg.boxes.filter((x) => x.kind === "output")) {
|
||||
const v = into(b.id)[OUTPUT_PORT] ?? null;
|
||||
const scalar: Scalar = Array.isArray(v) ? null : v;
|
||||
outputs[b.id] = scalar;
|
||||
const kind = b.output?.cost ?? "값";
|
||||
if (kind === "값") cost.값들[b.name] = scalar;
|
||||
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
|
||||
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
|
||||
for (const b of boxes) {
|
||||
const given = values[b.id] ?? {};
|
||||
if (b.kind === "input" && b.input?.mode !== "logic") {
|
||||
ports[b.id] = inputPorts(lg, b, given);
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) {
|
||||
const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) });
|
||||
found.push({ kind: "맞는 재료 없음", box: b.id, port: elementPort(e), text });
|
||||
errors.push(text);
|
||||
}
|
||||
} else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors);
|
||||
else if (b.kind === "function") {
|
||||
const got = arrive(b);
|
||||
ports[b.id] = {
|
||||
[b.func!.output.name]: blocked.has(b.id) ? null : runFunc(b, got, given, errors),
|
||||
};
|
||||
} else {
|
||||
const v = arrive(b)[OUTPUT_PORT] ?? null;
|
||||
let scalar: Scalar = Array.isArray(v) ? null : v;
|
||||
const round = b.output?.round;
|
||||
if (typeof scalar === "number" && round) scalar = applyRound(round, scalar);
|
||||
outputs[b.id] = scalar;
|
||||
const kind = b.output?.cost ?? "값";
|
||||
if (kind === "값") cost.값들[b.name] = scalar;
|
||||
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
|
||||
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
|
||||
}
|
||||
}
|
||||
cost.계 = cost.노무비 + cost.재료비 + cost.경비;
|
||||
return { ports, outputs, cost, errors };
|
||||
gates(lg, passed, name, found, errors);
|
||||
return { ports, arrived, outputs, cost, errors, breaks: found };
|
||||
}
|
||||
|
||||
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
|
||||
export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] {
|
||||
const out: string[] = [];
|
||||
/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */
|
||||
function otherLogic(
|
||||
box: CfBox,
|
||||
arrive: Record<string, CfValue>,
|
||||
given: Record<string, CfValue>,
|
||||
opts: CfEvalOptions,
|
||||
errors: string[],
|
||||
): Record<string, CfValue> {
|
||||
const ref = box.input?.logic;
|
||||
const out: Record<string, CfValue> = {};
|
||||
const other = ref ? opts.logics?.[ref.key] : undefined;
|
||||
if (!other || (opts.depth ?? 0) > 8) {
|
||||
if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name }));
|
||||
for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null;
|
||||
return out;
|
||||
}
|
||||
const inner: CfValues = {};
|
||||
for (const d of designInputs(other)) {
|
||||
const v = d.port in arrive ? arrive[d.port] : given[d.port];
|
||||
if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v;
|
||||
}
|
||||
const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 });
|
||||
for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`);
|
||||
for (const p of ref?.outputs ?? []) {
|
||||
const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name);
|
||||
out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */
|
||||
function gates(
|
||||
lg: CfLogic,
|
||||
passed: Map<string, boolean>,
|
||||
name: (id: string) => string,
|
||||
found: CfBreak[],
|
||||
errors: string[],
|
||||
): void {
|
||||
const groups = new Map<string, CfLink[]>();
|
||||
for (const l of lg.links)
|
||||
if (l.rule?.kind === "gate") {
|
||||
const k = `${l.rule.from.box}|${l.rule.from.name}|${l.from.box}|${l.from.name}`;
|
||||
groups.set(k, [...(groups.get(k) ?? []), l]);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
if (list.length < 2) continue;
|
||||
const n = list.filter((l) => passed.get(l.id)).length;
|
||||
if (n === 1) continue;
|
||||
const g = list[0].rule as Extract<CfLink["rule"], { kind: "gate" }>;
|
||||
const fill = { box: name(g.from.box), pill: g.from.name };
|
||||
const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill);
|
||||
found.push({
|
||||
kind: n > 1 ? "조건 겹침" : "조건 안 맞음",
|
||||
box: g.from.box,
|
||||
port: g.from.name,
|
||||
text,
|
||||
});
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 검사 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */
|
||||
export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] {
|
||||
const out: CfBreak[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
if (b.kind === "input") continue;
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
|
||||
out.push({
|
||||
kind: "빈 받는 자리",
|
||||
box: b.id,
|
||||
port: p.name,
|
||||
text: tl("EmptyIn", { box: b.name, pill: p.name }),
|
||||
});
|
||||
}
|
||||
return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])];
|
||||
}
|
||||
|
||||
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
|
||||
export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] {
|
||||
const out: string[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
if (b.kind !== "input")
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
|
||||
if (b.kind === "function" && !parseA1(b.func?.output.cell ?? ""))
|
||||
out.push(tl("NoCell", { box: b.name }));
|
||||
for (const i of b.func?.inputs ?? [])
|
||||
if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port }));
|
||||
}
|
||||
if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput"));
|
||||
const ev = evalLogic(lg, values);
|
||||
for (const b of lg.boxes.filter((x) => x.kind === "input"))
|
||||
const ev = evalLogic(lg, values, opts);
|
||||
for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic"))
|
||||
for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {}))
|
||||
if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill))
|
||||
out.push(tl("NoValue", { box: b.name, pill }));
|
||||
return [...out, ...ev.errors];
|
||||
}
|
||||
|
||||
/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */
|
||||
export interface CfBreak {
|
||||
kind:
|
||||
| "빈 받는 자리"
|
||||
| "통과 안 됨"
|
||||
| "표에 없는 조건"
|
||||
| "맞는 재료 없음"
|
||||
| "조건 겹침"
|
||||
| "조건 안 맞음"
|
||||
| "오류";
|
||||
box: string;
|
||||
port?: string;
|
||||
link?: string;
|
||||
/** 쉬운 말 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 끊긴 곳 목록(넓힘 · 준비 중) */
|
||||
export function breaks(_lg: CfLogic, _values: CfValues = {}): CfBreak[] {
|
||||
throw new Error("준비 중");
|
||||
return [...new Set([...out, ...ev.errors])];
|
||||
}
|
||||
|
||||
@@ -50,13 +50,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfpanel .ui-btn,
|
||||
.m02-dfpanel .ui-input,
|
||||
.m02-dfpanel .ui-select {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-dfpanel-form {
|
||||
align-items: flex-end;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
@@ -92,8 +85,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
padding: var(--spacing-4) var(--spacing-12);
|
||||
border: 2px solid;
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface);
|
||||
|
||||
@@ -7,100 +7,18 @@
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
CalcValues,
|
||||
SpreadsheetHandle,
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model";
|
||||
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
|
||||
import { createRoundRow } from "./M02_DF_RoundRow";
|
||||
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
|
||||
import "./M02_DF_FuncPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
Unit: ["단위", "Unit"],
|
||||
Cell: ["고른 칸", "Selected cell"],
|
||||
Got: ["받은 알약", "Received pills"],
|
||||
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
|
||||
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
|
||||
Placed: ["놓임", "placed"],
|
||||
Free: ["아직 안 놓은 알약", "not placed yet"],
|
||||
Busy: ["이미 쓰는 칸", "That cell is already in use"],
|
||||
Locked: [
|
||||
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
|
||||
"This cell is filled by the wire — cannot edit",
|
||||
],
|
||||
Vars: ["변수", "Variables"],
|
||||
MakeVar: ["변수 만들기", "Make variable"],
|
||||
NoVars: ["아직 변수가 없음", "No variables yet"],
|
||||
VarName: ["이름", "Name"],
|
||||
VarType: ["갈래", "Type"],
|
||||
Number: ["숫자", "Number"],
|
||||
Choice: ["선택", "Choice"],
|
||||
Text: ["글", "Text"],
|
||||
VarUnit: ["단위", "Unit"],
|
||||
VarMin: ["최소", "Min"],
|
||||
VarMax: ["최대", "Max"],
|
||||
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
|
||||
VarExample: ["예시값", "Example value"],
|
||||
VarCell: ["붙을 칸", "Attach to"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Remove: ["지움", "Remove"],
|
||||
NeedName: ["이름을 적음", "Enter a name"],
|
||||
SameName: ["같은 이름이 이미 있음", "That name already exists"],
|
||||
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
|
||||
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
|
||||
OutBtn: ["출력 칸으로", "Make output cell"],
|
||||
OutNow: ["출력", "Output"],
|
||||
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
|
||||
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
|
||||
Frames: ["틀", "Templates"],
|
||||
FrameMul: ["곱하기", "Multiply"],
|
||||
FrameMore: ["할증", "Markup"],
|
||||
FrameDiv: ["나누기", "Divide"],
|
||||
FrameIf: ["만약", "If"],
|
||||
FrameMin: ["최소", "Min"],
|
||||
FrameMax: ["최대", "Max"],
|
||||
FrameUp: ["올림", "Round up"],
|
||||
FrameLook: ["표에서 찾기", "Look up in table"],
|
||||
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
||||
ReadOnly: ["읽기만", "Read only"],
|
||||
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
||||
Problem: ["⚠ ", "⚠ "],
|
||||
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
|
||||
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
||||
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
||||
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
||||
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
||||
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
||||
Err_NA: ["찾는 값이 없음", "value not found"],
|
||||
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
||||
} as const;
|
||||
|
||||
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
||||
const FRAMES: [keyof typeof TEXT, string][] = [
|
||||
["FrameMul", "=B1*B2"],
|
||||
["FrameMore", "=B1*(1+B2/100)"],
|
||||
["FrameDiv", "=B1/B2"],
|
||||
["FrameIf", '=IF(B3="급",10,0)'],
|
||||
["FrameMin", "=MIN(B1,B2)"],
|
||||
["FrameMax", "=MAX(B1,B2)"],
|
||||
["FrameUp", "=ROUNDUP(B4,3)"],
|
||||
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
||||
];
|
||||
|
||||
const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
||||
"#DIV/0!": "Err_DIV0",
|
||||
"#NAME?": "Err_NAME",
|
||||
"#REF!": "Err_REF",
|
||||
"#VALUE!": "Err_VALUE",
|
||||
"#CYCLE!": "Err_CYCLE",
|
||||
"#N/A": "Err_NA",
|
||||
};
|
||||
|
||||
type Handlers = { change(lg: CfLogic): void };
|
||||
|
||||
interface VarForm {
|
||||
@@ -126,6 +44,8 @@ interface State {
|
||||
json: string;
|
||||
cell: string;
|
||||
form: VarForm | null;
|
||||
/** 단위 표준이 제안한 출력 소수자리 */
|
||||
suggest: number | null;
|
||||
parts: {
|
||||
head: HTMLElement;
|
||||
got: HTMLElement;
|
||||
@@ -317,20 +237,19 @@ function applyMarks(st: State): void {
|
||||
|
||||
function drawHead(st: State): void {
|
||||
const box = boxOf(st);
|
||||
const unit = el("input", {
|
||||
className: "ui-input m02-dfpanel-unit",
|
||||
attrs: { type: "text", "aria-label": tf("Unit") },
|
||||
});
|
||||
unit.value = box?.unit ?? "-";
|
||||
if (st.readOnly) unit.readOnly = true;
|
||||
unit.addEventListener("change", () => {
|
||||
const v = unit.value.trim() || "-";
|
||||
unit.value = v;
|
||||
patchBox(st, (b) => ({
|
||||
...b,
|
||||
unit: v,
|
||||
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func,
|
||||
}));
|
||||
const unit = createUnitPick({
|
||||
value: box?.unit ?? "-",
|
||||
readOnly: st.readOnly,
|
||||
extra: st.lg.resultUnit ? [st.lg.resultUnit] : [],
|
||||
ariaLabel: tf("Unit"),
|
||||
onPick: (v) => {
|
||||
patchBox(st, (x) => ({
|
||||
...x,
|
||||
unit: v,
|
||||
func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func,
|
||||
}));
|
||||
void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st)));
|
||||
},
|
||||
});
|
||||
st.parts.head.replaceChildren(
|
||||
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
|
||||
@@ -372,8 +291,21 @@ function formField(
|
||||
cls = "",
|
||||
): HTMLElement {
|
||||
const form = st.form!;
|
||||
if (key === "unit") {
|
||||
return el("label", {
|
||||
className: "m02-dfpanel-fieldbox",
|
||||
children: [
|
||||
el("span", { className: "m02-dfpanel-note", text: tf(label) }),
|
||||
createUnitPick({
|
||||
value: form.unit || "-",
|
||||
ariaLabel: tf(label),
|
||||
onPick: (u) => ((form.unit = u), drawVars(st)),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
const input = el("input", {
|
||||
className: `ui-input m02-dfpanel-field ${cls}`,
|
||||
className: `ui-input ui-input--sm m02-dfpanel-field ${cls}`,
|
||||
attrs: { type: "text", "aria-label": tf(label) },
|
||||
});
|
||||
input.value = form[key];
|
||||
@@ -443,7 +375,7 @@ function drawVars(st: State): void {
|
||||
);
|
||||
} else {
|
||||
const type = el("select", {
|
||||
className: "ui-select m02-dfpanel-field",
|
||||
className: "ui-select ui-select--sm m02-dfpanel-field",
|
||||
attrs: { "aria-label": tf("VarType") },
|
||||
});
|
||||
for (const [v, k] of [
|
||||
@@ -520,6 +452,16 @@ function drawBar(st: State): void {
|
||||
el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }),
|
||||
],
|
||||
}),
|
||||
...(f?.output.cell
|
||||
? [
|
||||
createRoundRow({
|
||||
round: f.output.round,
|
||||
suggest: st.suggest,
|
||||
readOnly: st.readOnly,
|
||||
onApply: (round) => writeRound(st, round),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("div", {
|
||||
className: "m02-dfpanel-row",
|
||||
children: [
|
||||
@@ -538,6 +480,14 @@ function drawBar(st: State): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */
|
||||
function writeRound(st: State, round: CfRound | null): void {
|
||||
patchFunc(st, (x) => {
|
||||
const { round: _old, ...rest } = x.output;
|
||||
return { ...x, output: round ? { ...rest, round } : rest };
|
||||
});
|
||||
}
|
||||
|
||||
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
|
||||
function setOutput(st: State, cell: string): void {
|
||||
const f = funcOf(st);
|
||||
@@ -681,6 +631,7 @@ export function renderFuncPanel(
|
||||
json: "",
|
||||
cell: "A1",
|
||||
form: null,
|
||||
suggest: null,
|
||||
parts,
|
||||
observer: null,
|
||||
redraw: () => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FuncText.ts
|
||||
* 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
export const TEXT = {
|
||||
Unit: ["단위", "Unit"],
|
||||
Cell: ["고른 칸", "Selected cell"],
|
||||
Got: ["받은 알약", "Received pills"],
|
||||
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
|
||||
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
|
||||
Placed: ["놓임", "placed"],
|
||||
Free: ["아직 안 놓은 알약", "not placed yet"],
|
||||
Busy: ["이미 쓰는 칸", "That cell is already in use"],
|
||||
Locked: [
|
||||
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
|
||||
"This cell is filled by the wire — cannot edit",
|
||||
],
|
||||
Vars: ["변수", "Variables"],
|
||||
MakeVar: ["변수 만들기", "Make variable"],
|
||||
NoVars: ["아직 변수가 없음", "No variables yet"],
|
||||
VarName: ["이름", "Name"],
|
||||
VarType: ["갈래", "Type"],
|
||||
Number: ["숫자", "Number"],
|
||||
Choice: ["선택", "Choice"],
|
||||
Text: ["글", "Text"],
|
||||
VarUnit: ["단위", "Unit"],
|
||||
VarMin: ["최소", "Min"],
|
||||
VarMax: ["최대", "Max"],
|
||||
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
|
||||
VarExample: ["예시값", "Example value"],
|
||||
VarCell: ["붙을 칸", "Attach to"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Remove: ["지움", "Remove"],
|
||||
NeedName: ["이름을 적음", "Enter a name"],
|
||||
SameName: ["같은 이름이 이미 있음", "That name already exists"],
|
||||
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
|
||||
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
|
||||
OutBtn: ["출력 칸으로", "Make output cell"],
|
||||
OutNow: ["출력", "Output"],
|
||||
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
|
||||
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
|
||||
Frames: ["틀", "Templates"],
|
||||
FrameMul: ["곱하기", "Multiply"],
|
||||
FrameMore: ["할증", "Markup"],
|
||||
FrameDiv: ["나누기", "Divide"],
|
||||
FrameIf: ["만약", "If"],
|
||||
FrameMin: ["최소", "Min"],
|
||||
FrameMax: ["최대", "Max"],
|
||||
FrameUp: ["올림", "Round up"],
|
||||
FrameLook: ["표에서 찾기", "Look up in table"],
|
||||
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
||||
ReadOnly: ["읽기만", "Read only"],
|
||||
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
||||
Problem: ["⚠ ", "⚠ "],
|
||||
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
|
||||
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
||||
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
||||
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
||||
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
||||
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
||||
Err_NA: ["찾는 값이 없음", "value not found"],
|
||||
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
||||
} as const;
|
||||
|
||||
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
||||
export const FRAMES: [keyof typeof TEXT, string][] = [
|
||||
["FrameMul", "=B1*B2"],
|
||||
["FrameMore", "=B1*(1+B2/100)"],
|
||||
["FrameDiv", "=B1/B2"],
|
||||
["FrameIf", '=IF(B3="급",10,0)'],
|
||||
["FrameMin", "=MIN(B1,B2)"],
|
||||
["FrameMax", "=MAX(B1,B2)"],
|
||||
["FrameUp", "=ROUNDUP(B4,3)"],
|
||||
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
||||
];
|
||||
|
||||
export const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
||||
"#DIV/0!": "Err_DIV0",
|
||||
"#NAME?": "Err_NAME",
|
||||
"#REF!": "Err_REF",
|
||||
"#VALUE!": "Err_VALUE",
|
||||
"#CYCLE!": "Err_CYCLE",
|
||||
"#N/A": "Err_NA",
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
.m02-dflink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dflink-head,
|
||||
.m02-dflink-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dflink-label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dflink-note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dflink-value {
|
||||
padding: 2px var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m02-dflink-num {
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.m02-dflink-warn {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 알약 판 */
|
||||
.m02-dfpill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dfpill-title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dfpill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m02-dfpill-step {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.m02-dfpill-rule {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_LinkPanel.ts
|
||||
* 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ·
|
||||
* 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfValues } from "./M02_DF_Eval";
|
||||
import {
|
||||
portsOut,
|
||||
reaches,
|
||||
type CfGateOp,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
type CfRound,
|
||||
type CfRule,
|
||||
} from "./M02_DF_Model";
|
||||
import {
|
||||
GATE_OPS,
|
||||
SCALE_OPS,
|
||||
evalSafe,
|
||||
gateReport,
|
||||
labelOf,
|
||||
portText,
|
||||
preview,
|
||||
unitOf,
|
||||
valueOf,
|
||||
} from "./M02_DF_Detail";
|
||||
import { suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import "./M02_DF_LinkPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["선 규칙", "Line rule"],
|
||||
Kinds: ["규칙 고르기", "Pick a rule"],
|
||||
K_none: ["없음", "None"],
|
||||
K_lookup: ["표에서 찾기", "Look up in table"],
|
||||
K_gate: ["조건 통과", "Pass if"],
|
||||
K_scale: ["바꾸기", "Change"],
|
||||
K_round: ["끝수", "Rounding"],
|
||||
Label: ["선 위 글", "Text on the line"],
|
||||
Before: ["지나가기 전 값", "Value before"],
|
||||
After: ["지난 뒤 값", "Value after"],
|
||||
Col: ["조건 칸", "Condition column"],
|
||||
Match: ["맞출 알약", "Pill to match"],
|
||||
Take: ["꺼낼 칸", "Column to take"],
|
||||
AddCond: ["+ 조건 칸", "+ Condition"],
|
||||
Remove: ["지움", "Remove"],
|
||||
NoHeader: [
|
||||
"보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름",
|
||||
"The sender is not a table or it is not loaded yet",
|
||||
],
|
||||
Pick: ["고르기", "pick"],
|
||||
GateHint: [
|
||||
"알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음",
|
||||
"Sent only when the pill passes",
|
||||
],
|
||||
Value: ["값", "Value"],
|
||||
ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"],
|
||||
Places: ["소수 {n}자리", "{n} decimals"],
|
||||
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
|
||||
Apply: ["적용", "Apply"],
|
||||
WarnBoth: [
|
||||
"같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침",
|
||||
"{n} conditional lines pass at once — overlap",
|
||||
],
|
||||
WarnNone: [
|
||||
"같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음",
|
||||
"None of the {n} conditional lines match",
|
||||
],
|
||||
WarnLookup: [
|
||||
"조건 칸이 없음 — 표에서 줄을 못 고름",
|
||||
"No condition column — a row can't be picked",
|
||||
],
|
||||
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
|
||||
Count: ["선", "Line"],
|
||||
} as const;
|
||||
|
||||
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
|
||||
|
||||
type Kind = "none" | CfRule["kind"];
|
||||
const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"];
|
||||
const SEP = "\u0001";
|
||||
|
||||
interface Ctx {
|
||||
lg: CfLogic;
|
||||
linkId: string;
|
||||
readOnly: boolean;
|
||||
headers: string[];
|
||||
change(lg: CfLogic): void;
|
||||
}
|
||||
|
||||
const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`;
|
||||
const fromKey = (k: string): CfPort => {
|
||||
const [box, ...rest] = k.split(SEP);
|
||||
return { box, name: rest.join(SEP) };
|
||||
};
|
||||
|
||||
function setRule(ctx: Ctx, rule: CfRule | undefined): void {
|
||||
ctx.change({
|
||||
...ctx.lg,
|
||||
links: ctx.lg.links.map((l) => {
|
||||
if (l.id !== ctx.linkId) return l;
|
||||
const { rule: _old, ...rest } = l;
|
||||
return rule ? { ...rest, rule } : rest;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function select(
|
||||
ctx: Ctx,
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
|
||||
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||||
if (!options.some((o) => o.value === value))
|
||||
s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") }));
|
||||
s.value = value;
|
||||
s.disabled = ctx.readOnly;
|
||||
s.addEventListener("change", () => onPick(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
function numberBox(
|
||||
ctx: Ctx,
|
||||
value: number | string,
|
||||
onCommit: (v: number | string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const i = el("input", {
|
||||
className: "ui-input ui-input--sm m02-dflink-num",
|
||||
attrs: { type: "text", "aria-label": aria },
|
||||
});
|
||||
i.value = String(value);
|
||||
i.readOnly = ctx.readOnly;
|
||||
i.addEventListener("change", () => {
|
||||
const t = i.value.trim().replace(/,/g, "");
|
||||
onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t);
|
||||
});
|
||||
return i;
|
||||
}
|
||||
|
||||
/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */
|
||||
const pillOptions = (ctx: Ctx): { value: string; text: string }[] => {
|
||||
const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? "";
|
||||
return ctx.lg.boxes
|
||||
.filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id))
|
||||
.flatMap((b) =>
|
||||
portsOut(b).map((p) => ({
|
||||
value: toKey({ box: b.id, name: p.name }),
|
||||
text: portText(ctx.lg, { box: b.id, name: p.name }),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const row = (...children: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", { className: "m02-dflink-row", children });
|
||||
|
||||
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
|
||||
const cols = [
|
||||
...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)),
|
||||
];
|
||||
const colOpts = cols.map((c) => ({ value: c, text: c }));
|
||||
const pills = pillOptions(ctx);
|
||||
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
|
||||
const rows = rule.conds.map((c, i) =>
|
||||
row(
|
||||
select(
|
||||
ctx,
|
||||
colOpts,
|
||||
c.col,
|
||||
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
|
||||
tk("Col"),
|
||||
),
|
||||
"=",
|
||||
select(
|
||||
ctx,
|
||||
pills,
|
||||
toKey(c.from),
|
||||
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }),
|
||||
tk("Match"),
|
||||
),
|
||||
ctx.readOnly
|
||||
? ""
|
||||
: createButton({
|
||||
label: tk("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out: HTMLElement[] = [...rows];
|
||||
if (!ctx.readOnly) {
|
||||
out.push(
|
||||
row(
|
||||
createButton({
|
||||
label: tk("AddCond"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !pills.length || !cols.length,
|
||||
onClick: () =>
|
||||
put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Take") }),
|
||||
select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")),
|
||||
),
|
||||
);
|
||||
if (!cols.length) out.push(el("p", { className: "m02-dflink-note", text: tk("NoHeader") }));
|
||||
return out;
|
||||
}
|
||||
|
||||
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
|
||||
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
|
||||
return [
|
||||
el("p", { className: "m02-dflink-note", text: tk("GateHint") }),
|
||||
row(
|
||||
select(
|
||||
ctx,
|
||||
pillOptions(ctx),
|
||||
toKey(rule.from),
|
||||
(v) => put({ from: fromKey(v) }),
|
||||
tk("Match"),
|
||||
),
|
||||
select(
|
||||
ctx,
|
||||
GATE_OPS.map((o) => ({ value: o, text: o })),
|
||||
rule.op,
|
||||
(v) => put({ op: v as CfGateOp }),
|
||||
tk("Kinds"),
|
||||
),
|
||||
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
|
||||
return [
|
||||
el("p", { className: "m02-dflink-note", text: tk("ScaleHint") }),
|
||||
row(
|
||||
select(
|
||||
ctx,
|
||||
SCALE_OPS.map((o) => ({ value: o, text: o })),
|
||||
rule.op,
|
||||
(v) => setRule(ctx, { ...rule, op: v as typeof rule.op }),
|
||||
tk("Kinds"),
|
||||
),
|
||||
numberBox(
|
||||
ctx,
|
||||
rule.value,
|
||||
(v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }),
|
||||
tk("Value"),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function roundEditor(
|
||||
ctx: Ctx,
|
||||
rule: Extract<CfRule, { kind: "round" }>,
|
||||
unit: string,
|
||||
): HTMLElement[] {
|
||||
const put = (next: Partial<CfRound>): void => setRule(ctx, { ...rule, ...next });
|
||||
const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) }));
|
||||
const hint = el("span", { className: "m02-dflink-note" });
|
||||
void suggestPlaces(unit).then((n) => {
|
||||
if (n === null || n === rule.places || ctx.readOnly) return;
|
||||
hint.append(
|
||||
tk("Suggest", n),
|
||||
" ",
|
||||
createButton({
|
||||
label: tk("Apply"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put({ places: n }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
return [
|
||||
row(
|
||||
select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")),
|
||||
select(
|
||||
ctx,
|
||||
(["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })),
|
||||
rule.method,
|
||||
(v) => put({ method: v as CfRound["method"] }),
|
||||
tk("K_round"),
|
||||
),
|
||||
hint,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
|
||||
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
|
||||
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
|
||||
scale: () => ({ kind: "scale", op: "×", value: 1 }),
|
||||
round: () => ({ kind: "round", places: 0, method: "반올림" }),
|
||||
};
|
||||
|
||||
/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */
|
||||
export function renderLinkPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
linkId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
opts: { values?: CfValues; readOnly?: boolean } = {},
|
||||
): void {
|
||||
const link = lg.links.find((l) => l.id === linkId);
|
||||
host.classList.add("m02-dflink");
|
||||
if (!link) return host.replaceChildren();
|
||||
const ev = evalSafe(lg, opts.values);
|
||||
const table = valueOf(ev, link.from);
|
||||
const headers =
|
||||
Array.isArray(table) && Array.isArray(table[0])
|
||||
? table[0].map((h) => String(h ?? "")).filter(Boolean)
|
||||
: [];
|
||||
const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change };
|
||||
const rule = link.rule;
|
||||
const kind: Kind = rule?.kind ?? "none";
|
||||
const parts: HTMLElement[] = [
|
||||
el("div", {
|
||||
className: "m02-dflink-head",
|
||||
children: [
|
||||
el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }),
|
||||
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
|
||||
],
|
||||
}),
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Kinds") }),
|
||||
...KINDS.map((k) =>
|
||||
createButton({
|
||||
label: tk(`K_${k}` as "K_none"),
|
||||
variant: k === kind ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
disabled: ctx.readOnly,
|
||||
onClick: () =>
|
||||
k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
];
|
||||
if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule));
|
||||
if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule));
|
||||
if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule));
|
||||
if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from)));
|
||||
|
||||
const pv = preview(link, rule, ev);
|
||||
parts.push(
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Before") }),
|
||||
el("span", { className: "m02-dflink-value", text: pv.before }),
|
||||
"→",
|
||||
el("span", { className: "m02-dflink-label", text: tk("After") }),
|
||||
el("span", { className: "m02-dflink-value", text: pv.after }),
|
||||
),
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Label") }),
|
||||
el("span", { text: labelOf(lg, rule) }),
|
||||
),
|
||||
);
|
||||
|
||||
const warns: string[] = [];
|
||||
if (rule?.kind === "gate") {
|
||||
const rep = gateReport(lg, link, ev);
|
||||
if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length));
|
||||
if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total));
|
||||
}
|
||||
if (rule?.kind === "lookup") {
|
||||
if (!rule.conds.length) warns.push(tk("WarnLookup"));
|
||||
if (!rule.col) warns.push(tk("WarnTake"));
|
||||
}
|
||||
parts.push(...warns.map((w) => el("p", { className: "m02-dflink-warn", text: `⚠ ${w}` })));
|
||||
host.replaceChildren(...parts);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Material.ts
|
||||
* 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials?q&sub&detail&page&size`
|
||||
* → {total, page, size, items}(아직 없으면 M01 재료 찾기로 대신) · 단가는 `cfValues`(재료 ref = 키)
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { searchElements } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { cfValues } from "./M02_DF_Api";
|
||||
|
||||
export interface CfMat {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface CfMatPage {
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
items: CfMat[];
|
||||
}
|
||||
|
||||
const text = (v: unknown): string =>
|
||||
typeof v === "string" || typeof v === "number" ? String(v) : "";
|
||||
|
||||
const norm = (it: Record<string, unknown>): CfMat => ({
|
||||
ref: text(it.ref ?? it.키),
|
||||
name: text(it.이름 ?? it.name),
|
||||
spec: text(it.규격 ?? it.spec),
|
||||
unit: text(it.단위 ?? it.unit),
|
||||
});
|
||||
|
||||
/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */
|
||||
export async function findMaterials(cond: {
|
||||
q?: string;
|
||||
sub?: string;
|
||||
detail?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<CfMatPage> {
|
||||
const page = cond.page ?? 1;
|
||||
const size = cond.size ?? 10;
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
q: cond.q ?? "",
|
||||
sub: cond.sub ?? "",
|
||||
detail: cond.detail ?? "",
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
});
|
||||
const r = await fetch(`${API_BASE_URL}/m02/df/materials?${qs.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = (await r.json()) as {
|
||||
total: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
return {
|
||||
total: d.total,
|
||||
page: d.page ?? page,
|
||||
size: d.size ?? size,
|
||||
items: d.items.map(norm),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* 서버 길이 아직 없으면 M01 재료 찾기로 */
|
||||
}
|
||||
const got = await searchElements("재료", cond.q ?? "");
|
||||
const all = got.items.filter((it) => {
|
||||
const x = it as unknown as Record<string, unknown>;
|
||||
return (!cond.sub || x.구분 === cond.sub) && (!cond.detail || x.상세구분 === cond.detail);
|
||||
});
|
||||
const from = (page - 1) * size;
|
||||
return {
|
||||
total: all.length,
|
||||
page,
|
||||
size,
|
||||
items: all.slice(from, from + size).map((it) => norm(it as unknown as Record<string, unknown>)),
|
||||
};
|
||||
}
|
||||
|
||||
const names = new Map<string, CfMat | null>();
|
||||
|
||||
/** 재료 키 → 이름 · 규격 (한 번 물은 것은 다시 안 물음) */
|
||||
export async function materialOf(key: string): Promise<CfMat | null> {
|
||||
if (!key) return null;
|
||||
if (names.has(key)) return names.get(key) ?? null;
|
||||
let hit: CfMat | null = null;
|
||||
try {
|
||||
const got = await searchElements("재료", key);
|
||||
const it = got.items.find((x) => x.ref === key);
|
||||
hit = it ? norm(it as unknown as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
hit = null;
|
||||
}
|
||||
names.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const prices = new Map<string, number | null>();
|
||||
|
||||
/** 재료 키 → 단가(원) — 못 구하면 null */
|
||||
export async function priceOf(key: string): Promise<number | null> {
|
||||
if (!key) return null;
|
||||
if (prices.has(key)) return prices.get(key) ?? null;
|
||||
let v: number | null = null;
|
||||
try {
|
||||
const a = (await cfValues([{ id: "p", group: "재료", ref: key }]))[0];
|
||||
v = a?.ok && typeof a.value === "number" ? a.value : null;
|
||||
} catch {
|
||||
v = null;
|
||||
}
|
||||
prices.set(key, v);
|
||||
return v;
|
||||
}
|
||||
@@ -193,6 +193,7 @@ const TEXT = {
|
||||
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
|
||||
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
|
||||
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
|
||||
CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
@@ -309,57 +310,100 @@ export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
|
||||
|
||||
/* ── 알약 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name
|
||||
* 함수 = func.output.name 하나 · 출력 = 없음 */
|
||||
/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */
|
||||
export const elementPort = (e: CfElementRef): string =>
|
||||
e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name;
|
||||
|
||||
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 variable.name
|
||||
* · 다른 로직이면 그 로직 출력 · 함수 = func.output.name 하나 · 출력 = 없음 */
|
||||
export function portsOut(box: CfBox): CfPill[] {
|
||||
if (box.kind === "function" && box.func)
|
||||
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
|
||||
if (box.kind !== "input" || !box.input) return [];
|
||||
const inp = box.input;
|
||||
if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p }));
|
||||
return [
|
||||
...(inp.mode === "group"
|
||||
? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" }))
|
||||
? inp.elements.map((e) => ({ name: elementPort(e), unit: e.unit || "-" }))
|
||||
: []),
|
||||
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
|
||||
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
|
||||
];
|
||||
}
|
||||
|
||||
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */
|
||||
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 다른 로직 입력 = 그 로직의 설계 때 정할 것 · 그 밖 입력 = 없음 */
|
||||
export function portsIn(box: CfBox): CfPill[] {
|
||||
if (box.kind === "function" && box.func)
|
||||
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
|
||||
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
|
||||
if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p }));
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */
|
||||
export function portDir(box: CfBox, name: string): "in" | "out" {
|
||||
if (box.kind === "output") return "in";
|
||||
if (portsOut(box).some((p) => p.name === name)) return "out";
|
||||
return "in";
|
||||
}
|
||||
|
||||
/* ── 선 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** from 상자에서 선을 따라 to 상자에 닿나 */
|
||||
/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */
|
||||
export function ruleSources(rule: CfRule | undefined): CfPort[] {
|
||||
if (!rule) return [];
|
||||
if (rule.kind === "lookup") return rule.conds.map((c) => c.from);
|
||||
if (rule.kind === "gate") return [rule.from];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */
|
||||
export function depends(lg: CfLogic): { from: string; to: string }[] {
|
||||
const out: { from: string; to: string }[] = [];
|
||||
for (const l of lg.links) {
|
||||
out.push({ from: l.from.box, to: l.to.box });
|
||||
for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box });
|
||||
}
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule) out.push({ from: e.rule.from.box, to: b.id });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** from 상자에서 기댐을 따라 to 상자에 닿나 */
|
||||
export function reaches(lg: CfLogic, from: string, to: string): boolean {
|
||||
const deps = depends(lg);
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string): boolean => {
|
||||
if (id === to) return true;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box));
|
||||
return deps.filter((d) => d.from === id).some((d) => walk(d.to));
|
||||
};
|
||||
return walk(from);
|
||||
}
|
||||
|
||||
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음
|
||||
/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 ·
|
||||
* 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음)
|
||||
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
|
||||
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
|
||||
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
|
||||
export function link(
|
||||
lg: CfLogic,
|
||||
from: CfPort,
|
||||
to: CfPort,
|
||||
rule?: CfRule,
|
||||
): { lg: CfLogic; why: string | null } {
|
||||
const a = boxOf(lg, from.box);
|
||||
const b = boxOf(lg, to.box);
|
||||
if (!a || !b) return { lg, why: tl("NoPill") };
|
||||
if (a.kind === "output") return { lg, why: tl("FromOutput") };
|
||||
if (b.kind === "input") return { lg, why: tl("ToInput") };
|
||||
if (b.kind === "input" && b.input?.mode !== "logic") return { lg, why: tl("ToInput") };
|
||||
if (a.id === b.id) return { lg, why: tl("Self") };
|
||||
if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") };
|
||||
const has = portsIn(b).some((p) => p.name === to.name);
|
||||
if (!has && b.kind !== "function") return { lg, why: tl("NoPill") };
|
||||
const why = ruleWhy(lg, b.id, rule);
|
||||
if (why) return { lg, why };
|
||||
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
|
||||
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
|
||||
return { lg, why: tl("Taken") };
|
||||
@@ -376,13 +420,37 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why:
|
||||
"l",
|
||||
next.links.map((l) => l.id),
|
||||
);
|
||||
return {
|
||||
lg: {
|
||||
...next,
|
||||
links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }],
|
||||
},
|
||||
why: null,
|
||||
};
|
||||
const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } };
|
||||
if (rule) made.rule = clone(rule);
|
||||
return { lg: { ...next, links: [...next.links, made] }, why: null };
|
||||
}
|
||||
|
||||
/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */
|
||||
function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null {
|
||||
for (const p of ruleSources(rule)) {
|
||||
const box = boxOf(lg, p.box);
|
||||
if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill");
|
||||
if (box.id === target || reaches(lg, target, box.id)) return tl("Loop");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */
|
||||
export function setRule(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
rule: CfRule | null,
|
||||
): { lg: CfLogic; why: string | null } {
|
||||
const l = lg.links.find((x) => x.id === id);
|
||||
if (!l) return { lg, why: tl("NoPill") };
|
||||
const why = rule ? ruleWhy(lg, l.to.box, rule) : null;
|
||||
if (why) return { lg, why };
|
||||
const links = lg.links.map((x) => {
|
||||
if (x.id !== id) return x;
|
||||
const { rule: _old, ...rest } = x;
|
||||
return rule ? { ...rest, rule: clone(rule) } : rest;
|
||||
});
|
||||
return { lg: { ...lg, links }, why: null };
|
||||
}
|
||||
|
||||
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
|
||||
@@ -403,23 +471,116 @@ export function unlink(lg: CfLogic, id: string): CfLogic {
|
||||
return updateBox(next, box.id, { func: { ...box.func, inputs } });
|
||||
}
|
||||
|
||||
/* ── 흐름(넓힘 · 준비 중) ───────────────────────────────────────────────── */
|
||||
/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터) */
|
||||
export function traceBack(_lg: CfLogic, _port: CfPort): CfStep[] {
|
||||
return notReady();
|
||||
/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */
|
||||
export function traceBack(lg: CfLogic, port: CfPort): CfStep[] {
|
||||
const steps: CfStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
|
||||
const box = boxOf(lg, p.box);
|
||||
const key = `${p.box}|${p.name}|${dir}`;
|
||||
if (!box || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
steps.push({
|
||||
box: p.box,
|
||||
port: p.name,
|
||||
dir,
|
||||
...(via ? { link: via.id } : {}),
|
||||
...(via?.rule ? { rule: clone(via.rule) } : {}),
|
||||
});
|
||||
if (dir === "in") {
|
||||
for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) {
|
||||
visit(l.from, "out");
|
||||
for (const c of ruleSources(l.rule)) visit(c, "out");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴
|
||||
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : [])
|
||||
visit(
|
||||
{ box: box.id, name: q.name },
|
||||
"in",
|
||||
lg.links.find((l) => l.to.box === box.id && l.to.name === q.name),
|
||||
);
|
||||
for (const e of box.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule && elementPort(e) === p.name) visit(e.rule.from, "out");
|
||||
};
|
||||
const box = boxOf(lg, port.box);
|
||||
if (!box) return [];
|
||||
const dir = portDir(box, port.name);
|
||||
visit(
|
||||
port,
|
||||
dir,
|
||||
dir === "in"
|
||||
? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name)
|
||||
: undefined,
|
||||
);
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** 그 알약 값이 가는 길 */
|
||||
export function traceForward(_lg: CfLogic, _port: CfPort): CfStep[] {
|
||||
return notReady();
|
||||
/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */
|
||||
export function traceForward(lg: CfLogic, port: CfPort): CfStep[] {
|
||||
const steps: CfStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
|
||||
const box = boxOf(lg, p.box);
|
||||
const key = `${p.box}|${p.name}|${dir}`;
|
||||
if (!box || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
steps.push({
|
||||
box: p.box,
|
||||
port: p.name,
|
||||
dir,
|
||||
...(via ? { link: via.id } : {}),
|
||||
...(via?.rule ? { rule: clone(via.rule) } : {}),
|
||||
});
|
||||
if (dir === "in") {
|
||||
// 받는 알약 → 그 상자의 내보내는 알약
|
||||
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : [])
|
||||
visit({ box: box.id, name: q.name }, "out");
|
||||
return;
|
||||
}
|
||||
for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name))
|
||||
visit(l.to, "in", l);
|
||||
// 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기
|
||||
for (const l of lg.links.filter((x) =>
|
||||
ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name),
|
||||
))
|
||||
visit(l.to, "in", l);
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule?.from.box === p.box && e.rule.from.name === p.name)
|
||||
visit({ box: b.id, name: elementPort(e) }, "out");
|
||||
};
|
||||
const box = boxOf(lg, port.box);
|
||||
if (!box) return [];
|
||||
visit(port, portDir(box, port.name));
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** 설계 때 정할 것 — 변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) */
|
||||
export function designInputs(_lg: CfLogic): CfDesign[] {
|
||||
return notReady();
|
||||
/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */
|
||||
export function designInputs(lg: CfLogic): CfDesign[] {
|
||||
const out: CfDesign[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
const vars = b.input?.variables ?? b.func?.variables ?? [];
|
||||
for (const v of vars)
|
||||
out.push({ box: b.id, port: v.name, kind: "변수", unit: v.unit || "-", variable: clone(v) });
|
||||
for (const e of b.input?.mode === "group" ? b.input.elements : [])
|
||||
if (e.mode === "choice")
|
||||
out.push({
|
||||
box: b.id,
|
||||
port: elementPort(e),
|
||||
kind: "재료 자리",
|
||||
unit: e.unit || "-",
|
||||
element: clone(e),
|
||||
});
|
||||
if (b.input?.mode === "logic")
|
||||
for (const d of b.input.logic?.designs ?? [])
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
|
||||
out.push({ box: b.id, port: d.name, kind: "다른 로직", unit: d.unit || "-" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -83,3 +83,8 @@
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-dfout__round {
|
||||
margin: var(--spacing-8) 0;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval";
|
||||
import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_DF_Model";
|
||||
import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model";
|
||||
import { defaultItemRound, roundText } from "./M02_DF_Round";
|
||||
import "./M02_DF_OutputPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -32,6 +33,14 @@ const TEXT = {
|
||||
Table: ["표", "Table"],
|
||||
Calc: ["계산 중이 아니에요", "Not calculated"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
Round: ["끝수", "Rounding"],
|
||||
RoundPlaces: ["자리", "Places"],
|
||||
RoundMethod: ["방법", "Method"],
|
||||
RoundDefault: ["끝수 기본", "Default rounding"],
|
||||
RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"],
|
||||
RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"],
|
||||
RoundReset: ["기본으로", "Back to default"],
|
||||
RoundUser: ["고른 끝수", "Chosen rounding"],
|
||||
PlainHint: [
|
||||
"원가 합계에 안 넣고 값으로만 보여요",
|
||||
"Shown as a value, not added to the cost total",
|
||||
@@ -152,7 +161,7 @@ export function renderOutputPanel(
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost },
|
||||
output: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost },
|
||||
})),
|
||||
),
|
||||
});
|
||||
@@ -167,7 +176,7 @@ export function renderOutputPanel(
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
unit: next,
|
||||
output: { cost: b.output?.cost ?? "노무비", unit: next },
|
||||
output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next },
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -177,6 +186,76 @@ export function renderOutputPanel(
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
|
||||
/* 끝수 — 기본 = 일위대가표 금액란 0.1원 미만 버림(표준 표를 읽음) · 값(비용 아님)은 정하지 않으면 그대로 */
|
||||
const round = box.output?.round;
|
||||
const stepName = (n: number): string => (n === 0 ? "1" : String(Number((10 ** -n).toFixed(n))));
|
||||
let fallback: CfRound = { places: 1, method: "버림" };
|
||||
const roundNote = el("span", { className: "m02-dfout__muted" });
|
||||
const noteOf = (r: CfRound | undefined): string =>
|
||||
r
|
||||
? `${to("RoundUser")}: ${roundText(r)}`
|
||||
: cost === "값"
|
||||
? to("RoundNone")
|
||||
: `${to("RoundDefault")}: ${to("RoundStd")} ${roundText(fallback)}`;
|
||||
roundNote.textContent = noteOf(round);
|
||||
if (cost !== "값")
|
||||
void defaultItemRound().then((r) => {
|
||||
fallback = r;
|
||||
roundNote.textContent = noteOf(round);
|
||||
});
|
||||
const setRound = (next: CfRound | undefined): void =>
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => {
|
||||
const output = { ...(b.output ?? { cost, unit: b.unit }) };
|
||||
if (next) output.round = next;
|
||||
else delete output.round;
|
||||
return { ...b, output };
|
||||
}),
|
||||
);
|
||||
const placesField = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: cost === "값" ? to("RoundNone") : to("RoundDefault") },
|
||||
...[0, 1, 2, 3, 4].map((n) => ({ value: String(n), text: `${stepName(n)}원 단위` })),
|
||||
],
|
||||
value: round ? String(round.places) : "",
|
||||
compact: true,
|
||||
ariaLabel: to("RoundPlaces"),
|
||||
onChange: (v) =>
|
||||
setRound(v === "" ? undefined : { places: Number(v), method: round?.method ?? "버림" }),
|
||||
});
|
||||
const methodField = createSelectField({
|
||||
options: (["버림", "올림", "반올림"] as const).map((m) => ({ value: m, text: `미만 ${m}` })),
|
||||
value: round?.method ?? fallback.method,
|
||||
compact: true,
|
||||
disabled: !round,
|
||||
ariaLabel: to("RoundMethod"),
|
||||
onChange: (v) => round && setRound({ places: round.places, method: v as CfRound["method"] }),
|
||||
});
|
||||
const roundBlock = el("div", {
|
||||
className: "m02-dfout__round",
|
||||
children: [
|
||||
el("strong", { text: to("Round") }),
|
||||
el("div", {
|
||||
className: "m02-dfout__fields",
|
||||
children: [
|
||||
field(to("RoundPlaces"), placesField.root),
|
||||
field(to("RoundMethod"), methodField.root),
|
||||
...(round
|
||||
? [
|
||||
createButton({
|
||||
label: to("RoundReset"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => setRound(undefined),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
roundNote,
|
||||
],
|
||||
});
|
||||
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }),
|
||||
el("div", {
|
||||
@@ -189,6 +268,7 @@ export function renderOutputPanel(
|
||||
}),
|
||||
],
|
||||
}),
|
||||
roundBlock,
|
||||
...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []),
|
||||
msg,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_PillPanel.ts
|
||||
* 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfValues } from "./M02_DF_Eval";
|
||||
import type { CfLogic, CfStep } from "./M02_DF_Model";
|
||||
import {
|
||||
boxOf,
|
||||
breakLines,
|
||||
evalSafe,
|
||||
isOut,
|
||||
labelOf,
|
||||
portText,
|
||||
showValue,
|
||||
trace,
|
||||
unitOf,
|
||||
valueOf,
|
||||
} from "./M02_DF_Detail";
|
||||
import "./M02_DF_LinkPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
Name: ["이름", "Name"],
|
||||
Value: ["값", "Value"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Kind_out: ["내보내는 알약", "Sending pill"],
|
||||
Kind_in: ["받는 알약", "Receiving pill"],
|
||||
From: ["온 길", "Where it came from"],
|
||||
To: ["가는 곳", "Where it goes"],
|
||||
Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"],
|
||||
End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"],
|
||||
Breaks: ["끊긴 곳", "Breaks"],
|
||||
NoBreak: ["끊긴 곳 없음", "No breaks"],
|
||||
Open: ["이 상자 보기", "Open this box"],
|
||||
Line: ["선", "Line"],
|
||||
} as const;
|
||||
|
||||
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dflink-row",
|
||||
children: [el("span", { className: "m02-dflink-label", text: label }), ...value],
|
||||
});
|
||||
|
||||
function steps(
|
||||
lg: CfLogic,
|
||||
list: CfStep[],
|
||||
empty: string,
|
||||
onBox?: (id: string) => void,
|
||||
): HTMLElement {
|
||||
if (!list.length) return el("p", { className: "m02-dflink-note", text: empty });
|
||||
return el("ol", {
|
||||
className: "m02-dfpill-list",
|
||||
children: list.map((s) => {
|
||||
const parts: (HTMLElement | string)[] = [
|
||||
s.dir === "out" ? "◀" : "▶",
|
||||
onBox
|
||||
? createButton({
|
||||
label: portText(lg, { box: s.box, name: s.port }),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => onBox(s.box),
|
||||
})
|
||||
: portText(lg, { box: s.box, name: s.port }),
|
||||
];
|
||||
if (s.rule)
|
||||
parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) }));
|
||||
return el("li", { className: "m02-dfpill-step", children: parts });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */
|
||||
export function renderPillPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
port: string,
|
||||
on: { pick?(boxId: string): void } = {},
|
||||
opts: { values?: CfValues } = {},
|
||||
): void {
|
||||
host.classList.add("m02-dfpill");
|
||||
const box = boxOf(lg, boxId);
|
||||
if (!box) return host.replaceChildren();
|
||||
const ev = evalSafe(lg, opts.values);
|
||||
const out = isOut(box, port);
|
||||
// 받는 알약의 값 = 그 자리로 들어오는 선의 보낸 알약 값(규칙 지나기 전)
|
||||
const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port);
|
||||
const value = out
|
||||
? valueOf(ev, { box: boxId, name: port })
|
||||
: incoming
|
||||
? valueOf(ev, incoming.from)
|
||||
: undefined;
|
||||
const unit = out
|
||||
? unitOf(lg, { box: boxId, name: port })
|
||||
: incoming
|
||||
? unitOf(lg, incoming.from)
|
||||
: box.unit;
|
||||
const back = trace(lg, { box: boxId, name: port }, "in");
|
||||
const forward = trace(lg, { box: boxId, name: port }, "out");
|
||||
const breaks = breakLines(lg, boxId, port);
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-dflink-head",
|
||||
children: [
|
||||
el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }),
|
||||
el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }),
|
||||
],
|
||||
}),
|
||||
line(tp("Name"), port),
|
||||
line(tp("Value"), el("span", { className: "m02-dflink-value", text: showValue(value) })),
|
||||
line(tp("Unit"), unit || "-"),
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("From") }),
|
||||
steps(lg, back, tp("Start"), on.pick),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("To") }),
|
||||
steps(lg, forward, tp("End"), on.pick),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("Breaks") }),
|
||||
...(breaks.length
|
||||
? breaks.map((b) => el("p", { className: "m02-dflink-warn", text: `⚠ ${b}` }))
|
||||
: [el("p", { className: "m02-dflink-note", text: tp("NoBreak") })]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Round.ts
|
||||
* 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어
|
||||
* 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림)
|
||||
* 표 읽기 = 서버 `GET /api/m02/df/standards` → {unit, money, saved} · 아직 없으면 `_standard/금액단위표준.json`
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { CfRound } from "./M02_DF_Model";
|
||||
|
||||
/** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */
|
||||
export interface CfMoneyRow {
|
||||
종목: string;
|
||||
단위?: string;
|
||||
지위: number;
|
||||
비고?: string;
|
||||
}
|
||||
|
||||
const FILES = import.meta.glob<{ 줄?: CfMoneyRow[] }>("./_standard/금액단위표준.json", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
/** 표 모양 둘(`{줄: […]}` · 줄 목록)을 줄 목록으로 */
|
||||
const rowsOf = (x: unknown): CfMoneyRow[] => {
|
||||
const list = Array.isArray(x) ? x : (x as { 줄?: unknown } | null)?.줄;
|
||||
return Array.isArray(list) ? (list as CfMoneyRow[]) : [];
|
||||
};
|
||||
|
||||
let loaded: Promise<CfMoneyRow[]> | null = null;
|
||||
|
||||
/** 금액 단위 표준 줄 — 사용자가 고쳐 저장한 것이 있으면 그것 · 서버가 없으면 처음 값 */
|
||||
export function loadMoneyStandard(): Promise<CfMoneyRow[]> {
|
||||
loaded ??= (async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const d = (await r.json()) as { money?: unknown; saved?: { money?: unknown } | null };
|
||||
const rows = rowsOf(d.saved?.money ?? d.money);
|
||||
if (rows.length) return rows;
|
||||
}
|
||||
} catch {
|
||||
/* 서버가 없으면 처음 값 */
|
||||
}
|
||||
return rowsOf(Object.values(FILES)[0]);
|
||||
})();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 표를 다시 읽게(표를 고친 뒤) */
|
||||
export function resetStandard(): void {
|
||||
loaded = null;
|
||||
}
|
||||
|
||||
/** 표 줄 → 끝수 — 지위 0.1 = 소수 1자리 · 1 이상 = 0자리 · 비고 「미만버림」 */
|
||||
export function roundOf(row: CfMoneyRow): CfRound {
|
||||
const step = row.지위 > 0 ? row.지위 : 1;
|
||||
const method =
|
||||
/올림/.test(row.비고 ?? "") && !/버림/.test(row.비고 ?? "")
|
||||
? "올림"
|
||||
: /반올림/.test(row.비고 ?? "")
|
||||
? "반올림"
|
||||
: "버림";
|
||||
return { places: step >= 1 ? 0 : Math.round(-Math.log10(step)), method };
|
||||
}
|
||||
|
||||
const find = (rows: CfMoneyRow[], word: string): CfMoneyRow | undefined =>
|
||||
rows.find((r) => r.종목.includes("일위대가표") && r.종목.includes(word));
|
||||
|
||||
/** 출력(비용) 기본 — 일위대가표 금액란 0.1원 미만 버림 */
|
||||
export async function defaultItemRound(): Promise<CfRound> {
|
||||
const row = find(await loadMoneyStandard(), "금액란");
|
||||
return row ? roundOf(row) : { places: 1, method: "버림" };
|
||||
}
|
||||
|
||||
/** 원가 합계 기본 — 일위대가표 계금 1원 미만 버림 */
|
||||
export async function defaultTotalRound(): Promise<CfRound> {
|
||||
const row = find(await loadMoneyStandard(), "계금");
|
||||
return row ? roundOf(row) : { places: 0, method: "버림" };
|
||||
}
|
||||
|
||||
/** 끝수 적용 — 소수 자리 · 방법(부동소수 오차는 아주 작은 값으로 눌러 줌) */
|
||||
export function roundBy(r: CfRound, v: number): number {
|
||||
const k = 10 ** r.places;
|
||||
const x = v * k;
|
||||
const eps = Math.abs(x) * 1e-12 + 1e-9;
|
||||
const f =
|
||||
r.method === "올림"
|
||||
? Math.ceil(x - eps)
|
||||
: r.method === "반올림"
|
||||
? Math.round(x + eps)
|
||||
: Math.floor(x + eps);
|
||||
return f / k;
|
||||
}
|
||||
|
||||
/** 「0.1원 미만 버림」 꼴 쉬운 말 */
|
||||
export function roundText(r: CfRound): string {
|
||||
const step = r.places > 0 ? Number((10 ** -r.places).toFixed(r.places)) : 1;
|
||||
return `${step}원 미만 ${r.method}`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_RoundRow.ts
|
||||
* 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추)
|
||||
* 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfRound } from "./M02_DF_Model";
|
||||
|
||||
const TEXT = {
|
||||
Label: ["끝수", "Rounding"],
|
||||
None: ["안 씀", "none"],
|
||||
Places: ["소수 {n}자리", "{n} decimals"],
|
||||
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
|
||||
Apply: ["적용", "Apply"],
|
||||
PlacesAria: ["소수자리", "Decimal places"],
|
||||
ModeAria: ["방법", "Method"],
|
||||
} as const;
|
||||
|
||||
const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"];
|
||||
|
||||
const tr = (key: keyof typeof TEXT, n?: number): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
|
||||
|
||||
export interface RoundRowOptions {
|
||||
/** 지금 끝수 — 없으면 undefined */
|
||||
round: CfRound | undefined;
|
||||
/** 단위 표준이 제안한 소수자리 */
|
||||
suggest: number | null;
|
||||
readOnly?: boolean;
|
||||
/** null = 끝수 벗김 */
|
||||
onApply(round: CfRound | null): void;
|
||||
}
|
||||
|
||||
export function createRoundRow(opts: RoundRowOptions): HTMLElement {
|
||||
const now = opts.round ?? null;
|
||||
const places = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
attrs: { "aria-label": tr("PlacesAria") },
|
||||
});
|
||||
places.append(el("option", { attrs: { value: "" }, text: tr("None") }));
|
||||
for (let n = 0; n <= 6; n++) {
|
||||
places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) }));
|
||||
}
|
||||
places.value = now ? String(now.places) : "";
|
||||
const method = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
attrs: { "aria-label": tr("ModeAria") },
|
||||
});
|
||||
for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m }));
|
||||
method.value = now?.method ?? "반올림";
|
||||
if (opts.readOnly) places.disabled = method.disabled = true;
|
||||
const emit = (): void =>
|
||||
opts.onApply(
|
||||
places.value === ""
|
||||
? null
|
||||
: { places: Number(places.value), method: method.value as CfRound["method"] },
|
||||
);
|
||||
places.addEventListener("change", emit);
|
||||
method.addEventListener("change", () => places.value !== "" && emit());
|
||||
const row = el("div", {
|
||||
className: "m02-dfpanel-row",
|
||||
children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method],
|
||||
});
|
||||
if (opts.suggest !== null && !now && !opts.readOnly) {
|
||||
row.append(
|
||||
el("span", { className: "m02-dfpanel-note", text: tr("Suggest", opts.suggest) }),
|
||||
createButton({
|
||||
label: tr("Apply"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
@@ -1,33 +1,210 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Rule.ts
|
||||
* 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음
|
||||
*
|
||||
* 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식
|
||||
* lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH
|
||||
* gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CfRound, CfRule } from "./M02_DF_Model";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model";
|
||||
|
||||
type Scalar = number | string | boolean | null;
|
||||
|
||||
/** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */
|
||||
export interface CfRuleResult {
|
||||
value: number | string | boolean | null;
|
||||
value: Scalar;
|
||||
why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null;
|
||||
/** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */
|
||||
formula: string;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Lookup: ["표에서 찾기", "Look up in table"],
|
||||
Gate: ["일 때만", "only when"],
|
||||
Places: ["소수 {n}자리", "{n} decimals"],
|
||||
Round: ["반올림", "round"],
|
||||
Up: ["올림", "round up"],
|
||||
Down: ["버림", "round down"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/** 엑셀 비교 기호 */
|
||||
const OP: Record<CfGateOp, string> = {
|
||||
"=": "=",
|
||||
"≠": "<>",
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
const ROUND_FN: Record<CfRound["method"], string> = {
|
||||
반올림: "ROUND",
|
||||
올림: "ROUNDUP",
|
||||
버림: "ROUNDDOWN",
|
||||
};
|
||||
|
||||
/** 선 위에 늘 보일 규칙 글 */
|
||||
export function ruleLabel(_rule: CfRule): string {
|
||||
throw new Error("준비 중");
|
||||
export function ruleLabel(rule: CfRule): string {
|
||||
switch (rule.kind) {
|
||||
case "lookup": {
|
||||
const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · ");
|
||||
return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`;
|
||||
}
|
||||
case "gate":
|
||||
return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`;
|
||||
case "scale":
|
||||
return rule.op === "+%" || rule.op === "-%"
|
||||
? `${rule.op[0]}${rule.value}%`
|
||||
: `${rule.op} ${rule.value}`;
|
||||
case "round":
|
||||
return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 값(lookup · gate) */
|
||||
const roundWord = (m: CfRound["method"]): string =>
|
||||
m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down");
|
||||
|
||||
/* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const fromCalc = (v: CalcValue | undefined): Scalar => {
|
||||
if (!v) return null;
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
};
|
||||
|
||||
const cellOf = (v: unknown): Cell | null =>
|
||||
v === null || v === undefined || v === ""
|
||||
? null
|
||||
: typeof v === "number" || typeof v === "string" || typeof v === "boolean"
|
||||
? { 값: v }
|
||||
: { 값: String(v) };
|
||||
|
||||
/** 칸들을 시트 하나로 셈 → 읽을 칸 값 */
|
||||
function run(칸: Record<string, Cell>, read: string[]): Scalar[] {
|
||||
const id = "rule";
|
||||
const values = recalcWorkbook({
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "DF",
|
||||
서식: [{}],
|
||||
시트: [{ id, 이름: "규칙", 칸 }],
|
||||
});
|
||||
return read.map((a) =>
|
||||
칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
|
||||
const lit = (v: number | string): string =>
|
||||
typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`;
|
||||
|
||||
/* ── 규칙 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */
|
||||
export function applyRule(
|
||||
_rule: CfRule,
|
||||
_value: unknown,
|
||||
_conds: Record<string, unknown> = {},
|
||||
rule: CfRule,
|
||||
value: unknown,
|
||||
conds: Record<string, unknown> = {},
|
||||
): CfRuleResult {
|
||||
throw new Error("준비 중");
|
||||
if (rule.kind === "lookup") return lookup(rule, value, conds);
|
||||
const 칸: Record<string, Cell> = {};
|
||||
const a1 = cellOf(Array.isArray(value) ? null : value);
|
||||
if (a1) 칸.A1 = a1;
|
||||
let formula: string;
|
||||
if (rule.kind === "gate") {
|
||||
const c1 = cellOf(conds[rule.from.name]);
|
||||
if (c1) 칸.C1 = c1;
|
||||
formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`;
|
||||
} else if (rule.kind === "scale") {
|
||||
const v = rule.value;
|
||||
formula =
|
||||
rule.op === "×"
|
||||
? `A1*${v}`
|
||||
: rule.op === "÷"
|
||||
? `A1/${v}`
|
||||
: rule.op === "+%"
|
||||
? `A1*(1+${v}/100)`
|
||||
: `A1*(1-${v}/100)`;
|
||||
} else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`;
|
||||
칸.B1 = { 식: formula };
|
||||
const [got] = run(칸, ["B1"]);
|
||||
if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula };
|
||||
if (isError(got)) return { value: null, why: "오류", formula };
|
||||
return { value: got, why: null, formula };
|
||||
}
|
||||
|
||||
/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) */
|
||||
function lookup(
|
||||
rule: Extract<CfRule, { kind: "lookup" }>,
|
||||
value: unknown,
|
||||
conds: Record<string, unknown>,
|
||||
): CfRuleResult {
|
||||
const block = Array.isArray(value) ? (value as unknown[][]) : [];
|
||||
const head = (block[0] ?? []).map((h) => String(h ?? ""));
|
||||
const rows = block.length - 1;
|
||||
const W = head.length;
|
||||
const 칸: Record<string, Cell> = {};
|
||||
block.forEach((line, r) =>
|
||||
line.forEach((x, c) => {
|
||||
const cell = cellOf(x);
|
||||
if (cell) 칸[`${colName(c)}${r + 1}`] = cell;
|
||||
}),
|
||||
);
|
||||
const at = (name: string): string | null => {
|
||||
const i = head.indexOf(name);
|
||||
return i < 0 ? null : colName(i);
|
||||
};
|
||||
const out = at(rule.col);
|
||||
// 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고
|
||||
const tests: ((r: number) => string)[] = [];
|
||||
let bad = !out || rows < 1;
|
||||
rule.conds.forEach((c, i) => {
|
||||
const ref = `$${colName(W + 1 + i)}$1`;
|
||||
const cv = cellOf(conds[c.from.name]);
|
||||
if (cv) 칸[`${colName(W + 1 + i)}1`] = cv;
|
||||
const same = at(c.col);
|
||||
const low = at(`${c.col}·아래`);
|
||||
const high = at(`${c.col}·위`);
|
||||
if (same) tests.push((r) => `${same}${r}=${ref}`);
|
||||
else if (low && high)
|
||||
tests.push(
|
||||
(r) =>
|
||||
`AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`,
|
||||
);
|
||||
else bad = true;
|
||||
});
|
||||
const hc = colName(W + 1 + rule.conds.length);
|
||||
const last = rows + 1;
|
||||
for (let r = 2; r <= last; r++)
|
||||
칸[`${hc}${r}`] = {
|
||||
식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1",
|
||||
};
|
||||
const range = `${hc}2:${hc}${Math.max(2, last)}`;
|
||||
const count = `${colName(W + 2 + rule.conds.length)}1`;
|
||||
const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()";
|
||||
칸[count] = { 식: `SUM(${range})` };
|
||||
칸.ZZ1 = { 식: formula };
|
||||
if (bad) return { value: null, why: "오류", formula };
|
||||
const [n, got] = run(칸, [count, "ZZ1"]);
|
||||
if (n === 0) return { value: null, why: "표에 없는 조건", formula };
|
||||
if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula };
|
||||
if (isError(got)) return { value: null, why: "오류", formula };
|
||||
return { value: got, why: null, formula };
|
||||
}
|
||||
|
||||
/** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */
|
||||
export function applyRound(_round: CfRound, _value: number): number {
|
||||
throw new Error("준비 중");
|
||||
export function applyRound(round: CfRound, value: number): number {
|
||||
const [got] = run(
|
||||
{ A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } },
|
||||
["B1"],
|
||||
);
|
||||
return typeof got === "number" ? got : value;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Sample.ts
|
||||
* 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬)
|
||||
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비
|
||||
* ② 공구손료(비율) — 노무비 × 율 → 출력 재료비
|
||||
* ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값
|
||||
* 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬)
|
||||
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비
|
||||
* ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비
|
||||
* ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비
|
||||
* ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비
|
||||
* ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직
|
||||
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
|
||||
* 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」).
|
||||
* 모양 = `M02_DF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port ·
|
||||
* output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨.
|
||||
* 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음.
|
||||
* 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함
|
||||
* `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -21,7 +23,18 @@ const NOTE: Record<string, readonly [string, string]> = {
|
||||
"변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비",
|
||||
"Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost",
|
||||
],
|
||||
rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material 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)",
|
||||
@@ -29,7 +42,7 @@ const NOTE: Record<string, readonly [string, string]> = {
|
||||
};
|
||||
|
||||
/** 보여 줄 차례 */
|
||||
const ORDER = ["masonry", "rate", "value"];
|
||||
const ORDER = ["masonry", "ground", "hume", "linked", "value"];
|
||||
|
||||
export interface CfSampleItem {
|
||||
/** 파일 이름(확장자 뺌) */
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { checkLogic, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
|
||||
import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model";
|
||||
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model";
|
||||
import { showValue } from "./M02_DF_OutputPanel";
|
||||
import { defaultTotalRound, roundBy, roundText } from "./M02_DF_Round";
|
||||
import { renderVerify, type CfSel } from "./M02_DF_Verify";
|
||||
import "./M02_DF_Summary.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -27,18 +29,7 @@ const TEXT = {
|
||||
HValue: ["값", "Value"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
Plain: ["값(비용 아님)", "Values (not costs)"],
|
||||
Sample: ["예시값", "Sample values"],
|
||||
SampleHint: [
|
||||
"여기서 바꾸면 변수 상자의 예시값도 바뀌어요",
|
||||
"Changing here changes the variable box too",
|
||||
],
|
||||
Problems: ["고칠 것", "To fix"],
|
||||
NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"],
|
||||
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
RoundTotal: ["합계 끝수", "Total rounding"],
|
||||
Plain3: ["값", "Value"],
|
||||
} as const;
|
||||
|
||||
@@ -54,67 +45,16 @@ const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense"
|
||||
|
||||
const costText = (c: string): string => (c === "값" ? ts("Plain3") : c);
|
||||
|
||||
/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */
|
||||
function emptyPlaces(lg: CfLogic): string[] {
|
||||
const outs = lg.boxes.filter((b) => b.kind === "output");
|
||||
if (!outs.length) return [ts("NoOutput")];
|
||||
return outs
|
||||
.filter((o) => !lg.links.some((l) => l.to.box === o.id))
|
||||
.map((o) => ts("NoLink", { box: o.name }));
|
||||
}
|
||||
|
||||
/** 변수 상자의 예시값 칸 하나 */
|
||||
function sampleField(
|
||||
v: CfVariable,
|
||||
put: (value: number | string) => void,
|
||||
msg: HTMLElement,
|
||||
): HTMLElement {
|
||||
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
|
||||
let control: HTMLElement;
|
||||
if (v.type === "choice" && v.choices?.length) {
|
||||
control = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: ts("Pick") },
|
||||
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
|
||||
],
|
||||
value: v.example === null ? "" : String(v.example),
|
||||
compact: true,
|
||||
ariaLabel: v.name,
|
||||
onChange: (s) => {
|
||||
const hit = v.choices?.find((c) => String(c) === s);
|
||||
if (hit !== undefined) put(hit);
|
||||
},
|
||||
}).root;
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m02-dfsum__input",
|
||||
attrs: {
|
||||
type: "text",
|
||||
inputmode: v.type === "number" ? "decimal" : "text",
|
||||
"aria-label": v.name,
|
||||
},
|
||||
});
|
||||
if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) });
|
||||
box.value = v.example === null ? "" : String(v.example);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
if (raw === "") return;
|
||||
if (v.type !== "number") return put(raw);
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) msg.textContent = ts("Bad");
|
||||
else put(n);
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
return el("label", { className: "m02-dfsum__field", children: [label, control] });
|
||||
}
|
||||
/** 원가 합계 끝수 — 기본 = 일위대가표 계금 1원 미만 버림(표를 읽어 오면 그 값) */
|
||||
let totalRound: CfRound = { places: 0, method: "버림" };
|
||||
void defaultTotalRound().then((r) => (totalRound = r));
|
||||
|
||||
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
|
||||
export function renderSummary(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on?: { change(lg: CfLogic): void },
|
||||
on?: { change(lg: CfLogic): void; select?(sel: CfSel): void },
|
||||
opts?: { values?: CfValues },
|
||||
): void {
|
||||
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
|
||||
@@ -127,14 +67,14 @@ export function renderSummary(
|
||||
className: "m02-dfsum__big",
|
||||
children: [
|
||||
el("span", { text: money ? perText : ts("Total") }),
|
||||
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
|
||||
el("strong", { text: `${formatMoney(roundBy(totalRound, cost.계))} ${ts("Won")}` }),
|
||||
],
|
||||
});
|
||||
const costs = el("dl", {
|
||||
className: "m02-dfsum__costs",
|
||||
children: COSTS.flatMap((c) => [
|
||||
el("dt", { text: ts(COST_KEY[c]) }),
|
||||
el("dd", { text: formatMoney(cost[c]) }),
|
||||
el("dd", { text: formatMoney(roundBy(totalRound, cost[c])) }),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -185,52 +125,14 @@ export function renderSummary(
|
||||
],
|
||||
});
|
||||
|
||||
/* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */
|
||||
let checked: string[] = [];
|
||||
try {
|
||||
checked = checkLogic(lg, opts?.values ?? {});
|
||||
} catch {
|
||||
checked = [];
|
||||
}
|
||||
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
|
||||
|
||||
/* 예시값 — 변수 상자의 변수마다 */
|
||||
const msg = el("p", { className: "m02-dfsum__msg" });
|
||||
const fields = lg.boxes
|
||||
.filter((b) => b.kind === "input" && b.input?.mode === "variable")
|
||||
.flatMap((b) =>
|
||||
(b.input?.variables ?? []).map((v, i) =>
|
||||
sampleField(
|
||||
v,
|
||||
(value) => {
|
||||
msg.textContent = "";
|
||||
if (!on) return;
|
||||
try {
|
||||
on.change({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((x) =>
|
||||
x.id === b.id && x.input
|
||||
? {
|
||||
...x,
|
||||
input: {
|
||||
...x.input,
|
||||
variables: x.input.variables.map((y, j) =>
|
||||
j === i ? { ...y, example: value } : y,
|
||||
),
|
||||
},
|
||||
}
|
||||
: x,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
msg.textContent = ts("Cant");
|
||||
}
|
||||
},
|
||||
msg,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const verifyHost = el("div", { className: "m02-dfsum__verify" });
|
||||
renderVerify(
|
||||
verifyHost,
|
||||
lg,
|
||||
result,
|
||||
{ change: (next) => on?.change(next), select: on?.select },
|
||||
{ values: opts?.values },
|
||||
);
|
||||
const plainValues = Object.entries(cost.값들);
|
||||
host.replaceChildren(
|
||||
big,
|
||||
@@ -251,29 +153,10 @@ export function renderSummary(
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(fields.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfsum__strip",
|
||||
children: [
|
||||
el("strong", { text: ts("Sample") }),
|
||||
el("div", { className: "m02-dfsum__fields", children: fields }),
|
||||
el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }),
|
||||
msg,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(problems.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfsum__problems",
|
||||
children: [
|
||||
el("strong", { text: ts("Problems") }),
|
||||
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("p", {
|
||||
className: "m02-dfsum__muted",
|
||||
text: `${ts("RoundTotal")}: ${roundText(totalRound)}`,
|
||||
}),
|
||||
verifyHost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.m02-df-unitpick {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.m02-df-unitpick__panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 14em;
|
||||
max-height: 20em;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.m02-df-unitpick__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.m02-df-unitpick__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-df-unitpick__item.is-on,
|
||||
.m02-df-unitpick__item:hover {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-df-unitpick__item.is-now {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-df-unitpick__empty {
|
||||
padding: var(--spacing-8);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_UnitPick.ts
|
||||
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음
|
||||
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원
|
||||
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 처음 값 `_standard/단위표준.json`) — 다른 판도 이것을 씀
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import "./M02_DF_UnitPick.css";
|
||||
|
||||
const TEXT = {
|
||||
None: ["없음", "none"],
|
||||
Filter: ["단위를 쳐서 찾음", "Type to filter units"],
|
||||
Empty: ["맞는 단위가 없음 — 목록에서만 고름", "No match — pick from the list only"],
|
||||
Pick: ["단위 고르기", "Pick a unit"],
|
||||
} as const;
|
||||
|
||||
const tu = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/** 「없음」 을 뜻하는 단위 글 */
|
||||
export const NO_UNIT = "-";
|
||||
|
||||
/** 표준 표에서 읽은 것 — 단위 목록 · 단위마다 소수자리 제안(단위수량 쪽에서 가장 많이 쓴 자리) */
|
||||
export interface UnitStandard {
|
||||
units: string[];
|
||||
places: Record<string, number>;
|
||||
}
|
||||
|
||||
type UnitSource = () => Promise<UnitStandard>;
|
||||
|
||||
interface StandardRow {
|
||||
"규격 단위"?: string | null;
|
||||
"단위수량 단위"?: string | null;
|
||||
"단위수량 소수자리"?: number | null;
|
||||
}
|
||||
|
||||
/** 처음 값 — 번들에 든 표준 표(단위표준.json) */
|
||||
const bundled: UnitSource = async () => {
|
||||
const table = (await import("./_standard/단위표준.json")).default as { 줄: StandardRow[] };
|
||||
const units = table.줄
|
||||
.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]])
|
||||
.filter((u): u is string => !!u);
|
||||
const count: Record<string, Record<number, number>> = {};
|
||||
for (const r of table.줄) {
|
||||
const u = r["단위수량 단위"];
|
||||
const p = r["단위수량 소수자리"];
|
||||
if (!u || u === "없음" || typeof p !== "number") continue;
|
||||
count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 };
|
||||
}
|
||||
const places = Object.fromEntries(
|
||||
Object.entries(count).map(([u, c]) => [
|
||||
u,
|
||||
Number(Object.entries(c).sort((a, b) => b[1] - a[1])[0][0]),
|
||||
]),
|
||||
);
|
||||
return { units, places };
|
||||
};
|
||||
|
||||
let source: UnitSource = bundled;
|
||||
let cache: Promise<UnitStandard> | null = null;
|
||||
|
||||
/** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */
|
||||
export function setUnitSource(next: UnitSource): void {
|
||||
source = next;
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 */
|
||||
export function loadStandard(): Promise<UnitStandard> {
|
||||
cache ??= source().then((std) => {
|
||||
const set = new Set([...std.units, "원"]);
|
||||
set.delete("없음");
|
||||
set.delete(NO_UNIT);
|
||||
return { units: [...set], places: std.places };
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** 단위를 고르면 표준 표에서 소수자리 제안 — 없으면 null */
|
||||
export async function suggestPlaces(unit: string): Promise<number | null> {
|
||||
return (await loadStandard()).places[unit] ?? null;
|
||||
}
|
||||
|
||||
export interface UnitPickOptions {
|
||||
/** 지금 단위 — 「-」 = 없음 */
|
||||
value: string;
|
||||
onPick(unit: string): void;
|
||||
/** 더 보일 단위(마스터 결과단위 등) */
|
||||
extra?: string[];
|
||||
readOnly?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** 단위 고르기 — 단추를 누르면 걸러지는 목록 · 목록에서 고르기만 됨 */
|
||||
export function createUnitPick(opts: UnitPickOptions): HTMLElement {
|
||||
const label = (u: string): string => (u === NO_UNIT ? `${NO_UNIT} (${tu("None")})` : u);
|
||||
const root = el("span", { className: "m02-df-unitpick" });
|
||||
const open = createButton({
|
||||
label: opts.value || NO_UNIT,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: opts.readOnly,
|
||||
onClick: () => toggle(),
|
||||
});
|
||||
open.setAttribute("aria-label", `${opts.ariaLabel ?? tu("Pick")}: ${opts.value || NO_UNIT}`);
|
||||
open.setAttribute("aria-haspopup", "listbox");
|
||||
root.append(open);
|
||||
if (opts.readOnly) return root;
|
||||
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm m02-df-unitpick__filter",
|
||||
attrs: { type: "text", placeholder: tu("Filter"), "aria-label": tu("Filter") },
|
||||
});
|
||||
const list = el("ul", { className: "m02-df-unitpick__list", attrs: { role: "listbox" } });
|
||||
const panel = el("div", { className: "m02-df-unitpick__panel", children: [input, list] });
|
||||
panel.hidden = true;
|
||||
root.append(panel);
|
||||
|
||||
let all: string[] = [];
|
||||
let shown: string[] = [];
|
||||
let at = 0;
|
||||
const pick = (u: string): void => {
|
||||
close();
|
||||
if (u !== (opts.value || NO_UNIT)) opts.onPick(u);
|
||||
};
|
||||
const draw = (): void => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
shown = all.filter((u) => u === NO_UNIT || !q || u.toLowerCase().includes(q));
|
||||
if (q && !NO_UNIT.includes(q)) shown = shown.filter((u) => u !== NO_UNIT);
|
||||
at = Math.min(at, Math.max(0, shown.length - 1));
|
||||
list.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map((u, i) => {
|
||||
const b = el("button", {
|
||||
className: `m02-df-unitpick__item${i === at ? " is-on" : ""}${u === (opts.value || NO_UNIT) ? " is-now" : ""}`,
|
||||
text: label(u),
|
||||
attrs: { type: "button", role: "option" },
|
||||
});
|
||||
b.addEventListener("click", () => pick(u));
|
||||
return el("li", { children: [b] });
|
||||
})
|
||||
: [el("li", { className: "m02-df-unitpick__empty", text: tu("Empty") })]),
|
||||
);
|
||||
};
|
||||
const close = (): void => {
|
||||
panel.hidden = true;
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
};
|
||||
const outside = (e: Event): void => {
|
||||
if (!root.contains(e.target as Node)) close();
|
||||
};
|
||||
function toggle(): void {
|
||||
if (!panel.hidden) return close();
|
||||
panel.hidden = false;
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
input.value = "";
|
||||
void loadStandard().then(({ units }) => {
|
||||
const mine = [...(opts.extra ?? []), ...units];
|
||||
all = [NO_UNIT, ...new Set(mine.filter((u) => u && u !== NO_UNIT))];
|
||||
at = 0;
|
||||
draw();
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
input.addEventListener("input", () => ((at = 0), draw()));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") return (e.stopPropagation(), close(), open.focus());
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
at = Math.max(0, Math.min(shown.length - 1, at + (e.key === "ArrowDown" ? 1 : -1)));
|
||||
return draw();
|
||||
}
|
||||
if (e.key === "Enter" && shown[at] !== undefined) {
|
||||
e.preventDefault();
|
||||
pick(shown[at]);
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
.m02-dfver__block {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dfver__breaks {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m02-dfver__title {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-dfver__group {
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m02-dfver__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m02-dfver__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfver__input {
|
||||
box-sizing: border-box;
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfver__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m02-dfver__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfver__now {
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dfver__price {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-dfver__warn {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-dfver__panel {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m02-dfver__search,
|
||||
.m02-dfver__pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m02-dfver__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: var(--spacing-4) 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m02-dfver__list .ui-btn {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m02-dfver__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-dfver__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Verify.ts
|
||||
* 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부:
|
||||
* 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭)
|
||||
* · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select.
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { breaks, type CfBreak, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
|
||||
import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material";
|
||||
import {
|
||||
portsIn,
|
||||
type CfBox,
|
||||
type CfElementRef,
|
||||
type CfLogic,
|
||||
type CfVariable,
|
||||
} from "./M02_DF_Model";
|
||||
import "./M02_DF_Verify.css";
|
||||
|
||||
const TEXT = {
|
||||
Breaks: ["끊긴 곳", "Broken spots"],
|
||||
BreaksHint: ["누르면 그 상자 · 선을 골라요", "Click to select the box or line"],
|
||||
Design: ["설계 때 정할 것", "To decide at design time"],
|
||||
DesignHint: ["여기서 바꾸면 해당 상자도 바뀌어요", "Changing here changes the box too"],
|
||||
Vars: ["변수 예시값", "Variable samples"],
|
||||
Slots: ["재료 선택 자리", "Material choice"],
|
||||
Rules: ["재료 조건 자리", "Material by condition"],
|
||||
Logics: ["이어지지 않은 다른 로직 값", "Unlinked values of other logics"],
|
||||
Choose: ["고르기", "Choose"],
|
||||
Close: ["닫기", "Close"],
|
||||
Find: ["찾기", "Find"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
Search: ["재료 이름 · 규격", "Material name or size"],
|
||||
Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"],
|
||||
NoHit: ["찾은 재료가 없어요", "No material found"],
|
||||
NoDefault: ["아직 고르지 않았어요", "Nothing chosen yet"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Won: ["원", "KRW"],
|
||||
Loading: ["찾는 중", "Searching"],
|
||||
NeedValue: ["조건 값이 아직 없어요", "The condition value is not there yet"],
|
||||
Caught: ["잡힌 품목", "Matched item"],
|
||||
Zero: ["맞는 재료 없음(0)", "No matching material (0)"],
|
||||
Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"],
|
||||
Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
OtherLogic: [
|
||||
"이어 주지 않아서 그 로직의 예시값을 써요",
|
||||
"Not linked — the logic's own sample is used",
|
||||
],
|
||||
NoLine: ["「{box}」 의 받는 자리 「{pill}」 에 선이 없어요", "“{pill}” of “{box}” has no line"],
|
||||
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tv = (key: TextKey, fill: Record<string, string | number> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) =>
|
||||
k in fill ? String(fill[k]) : m,
|
||||
);
|
||||
|
||||
/** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */
|
||||
export interface CfSel {
|
||||
box: string;
|
||||
port?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface CfVerifyOn {
|
||||
change(lg: CfLogic): void;
|
||||
select?(sel: CfSel): void;
|
||||
}
|
||||
|
||||
const PAGE = 10;
|
||||
|
||||
/** 끊긴 곳 — Model 쪽 `breaks` 가 차면 그것 · 아직이면 빈 받는 자리와 계산 오류만 */
|
||||
function brokenSpots(lg: CfLogic, result: CfEvalResult, values: CfValues): CfBreak[] {
|
||||
try {
|
||||
return breaks(lg, values);
|
||||
} catch {
|
||||
const out: CfBreak[] = [];
|
||||
if (!lg.boxes.some((b) => b.kind === "output"))
|
||||
out.push({ kind: "오류", box: "", text: tv("NoOutput") });
|
||||
for (const b of lg.boxes)
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push({
|
||||
kind: "빈 받는 자리",
|
||||
box: b.id,
|
||||
port: p.name,
|
||||
text: tv("NoLine", { box: b.name, pill: p.name }),
|
||||
});
|
||||
for (const e of result.errors) out.push({ kind: "오류", box: "", text: e });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** 재료 요소 하나 고치기 — 나머지는 그대로 */
|
||||
function withElement(
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
name: string,
|
||||
patch: Partial<CfElementRef>,
|
||||
): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) =>
|
||||
b.id === boxId && b.input
|
||||
? {
|
||||
...b,
|
||||
input: {
|
||||
...b.input,
|
||||
elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)),
|
||||
},
|
||||
}
|
||||
: b,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const matText = (m: CfMat): string =>
|
||||
`${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`;
|
||||
|
||||
/** 변수 예시값 칸 */
|
||||
function sampleField(
|
||||
v: CfVariable,
|
||||
put: (value: number | string) => void,
|
||||
msg: HTMLElement,
|
||||
): HTMLElement {
|
||||
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
|
||||
let control: HTMLElement;
|
||||
if (v.type === "choice" && v.choices?.length) {
|
||||
control = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: tv("Pick") },
|
||||
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
|
||||
],
|
||||
value: v.example === null ? "" : String(v.example),
|
||||
compact: true,
|
||||
ariaLabel: v.name,
|
||||
onChange: (s) => {
|
||||
const hit = v.choices?.find((c) => String(c) === s);
|
||||
if (hit !== undefined) put(hit);
|
||||
},
|
||||
}).root;
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m02-dfver__input",
|
||||
attrs: {
|
||||
type: "text",
|
||||
inputmode: v.type === "number" ? "decimal" : "text",
|
||||
"aria-label": v.name,
|
||||
},
|
||||
});
|
||||
if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: v.range[1] });
|
||||
box.value = v.example === null ? "" : String(v.example);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
if (raw === "") return;
|
||||
if (v.type !== "number") return put(raw);
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) msg.textContent = tv("Bad");
|
||||
else put(n);
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
return el("label", { className: "m02-dfver__field", children: [label, control] });
|
||||
}
|
||||
|
||||
/** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */
|
||||
function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement {
|
||||
const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") });
|
||||
const price = el("span", { className: "m02-dfver__price" });
|
||||
if (e.default) {
|
||||
void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? "")));
|
||||
void priceOf(e.default).then((p) => {
|
||||
price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`;
|
||||
});
|
||||
}
|
||||
const panel = el("div", { className: "m02-dfver__panel" });
|
||||
panel.hidden = true;
|
||||
const q = el("input", {
|
||||
className: "m02-dfver__input",
|
||||
attrs: { type: "text", placeholder: tv("Search"), "aria-label": tv("Search") },
|
||||
});
|
||||
q.value = e.scope?.찾는말 ?? "";
|
||||
const list = el("ul", { className: "m02-dfver__list" });
|
||||
const pager = el("div", { className: "m02-dfver__pager" });
|
||||
let page = 1;
|
||||
const load = async (): Promise<void> => {
|
||||
list.replaceChildren(el("li", { className: "m02-dfver__muted", text: tv("Loading") }));
|
||||
const got = await findMaterials({
|
||||
q: q.value.trim(),
|
||||
sub: e.scope?.구분,
|
||||
detail: e.scope?.상세구분,
|
||||
page,
|
||||
size: PAGE,
|
||||
});
|
||||
const pages = Math.max(1, Math.ceil(got.total / PAGE));
|
||||
list.replaceChildren(
|
||||
...(got.items.length
|
||||
? got.items.map((m) =>
|
||||
el("li", {
|
||||
children: [
|
||||
createButton({
|
||||
label: matText(m),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
: [el("li", { className: "m02-dfver__muted", text: tv("NoHit") })]),
|
||||
);
|
||||
pager.replaceChildren(
|
||||
createButton({
|
||||
label: tv("Prev"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
disabled: page <= 1,
|
||||
onClick: () => {
|
||||
page--;
|
||||
void load();
|
||||
},
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-dfver__muted",
|
||||
text: tv("Page", { p: got.page, n: pages, t: got.total }),
|
||||
}),
|
||||
createButton({
|
||||
label: tv("Next"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
disabled: page >= pages,
|
||||
onClick: () => {
|
||||
page++;
|
||||
void load();
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
q.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
page = 1;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
const toggle = createButton({
|
||||
label: tv("Choose"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden
|
||||
? tv("Choose")
|
||||
: tv("Close");
|
||||
if (!panel.hidden) void load();
|
||||
},
|
||||
});
|
||||
panel.append(
|
||||
el("div", {
|
||||
className: "m02-dfver__search",
|
||||
children: [
|
||||
q,
|
||||
createButton({
|
||||
label: tv("Find"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
page = 1;
|
||||
void load();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
list,
|
||||
pager,
|
||||
);
|
||||
return el("div", {
|
||||
className: "m02-dfver__item",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-dfver__line",
|
||||
children: [el("strong", { text: e.slot ?? e.name }), now, price, toggle],
|
||||
}),
|
||||
panel,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */
|
||||
function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement {
|
||||
const rule = e.rule!;
|
||||
const raw = result.ports[rule.from.box]?.[rule.from.name];
|
||||
const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw);
|
||||
const state = el("span", {
|
||||
className: "m02-dfver__now",
|
||||
text: value ? tv("Loading") : tv("NeedValue"),
|
||||
});
|
||||
const note = el("span", { className: "m02-dfver__muted" });
|
||||
if (value) {
|
||||
void findMaterials({
|
||||
q: e.scope?.찾는말,
|
||||
sub: e.scope?.구분,
|
||||
detail: e.scope?.상세구분,
|
||||
size: 100,
|
||||
}).then((got) => {
|
||||
const hit = got.items.filter((m) => {
|
||||
const t = rule.field === "규격" ? m.spec : m.name;
|
||||
return rule.op === "같음" ? t === value : t.includes(value);
|
||||
});
|
||||
const first = hit[0];
|
||||
state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`;
|
||||
state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`;
|
||||
note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : "";
|
||||
});
|
||||
}
|
||||
const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? "";
|
||||
return el("div", {
|
||||
className: "m02-dfver__item",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-dfver__line",
|
||||
children: [el("strong", { text: e.slot ?? e.name }), state, note],
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-dfver__muted",
|
||||
text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */
|
||||
export function renderVerify(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on: CfVerifyOn,
|
||||
opts: { values?: CfValues } = {},
|
||||
): void {
|
||||
const msg = el("p", { className: "m02-dfver__msg" });
|
||||
const safe = (make: () => CfLogic): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
msg.textContent = tv("Cant");
|
||||
}
|
||||
};
|
||||
|
||||
/* 끊긴 곳 */
|
||||
const spots = brokenSpots(lg, result, opts.values ?? {});
|
||||
const spotBlock = spots.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfver__block m02-dfver__breaks",
|
||||
children: [
|
||||
el("strong", { text: tv("Breaks") }),
|
||||
el("ul", {
|
||||
className: "m02-dfver__list",
|
||||
children: spots.map((s) =>
|
||||
el("li", {
|
||||
children: [
|
||||
createButton({
|
||||
label: s.text,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !s.box || !on.select,
|
||||
onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
el("p", { className: "m02-dfver__muted", text: tv("BreaksHint") }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
/* 설계 때 정할 것 */
|
||||
const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input);
|
||||
const vars = inputs.flatMap((b) =>
|
||||
(b.input?.variables ?? []).map((v, i) =>
|
||||
sampleField(
|
||||
v,
|
||||
(value) =>
|
||||
safe(() => ({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((x) =>
|
||||
x.id === b.id && x.input
|
||||
? {
|
||||
...x,
|
||||
input: {
|
||||
...x.input,
|
||||
variables: x.input.variables.map((y, j) =>
|
||||
j === i ? { ...y, example: value } : y,
|
||||
),
|
||||
},
|
||||
}
|
||||
: x,
|
||||
),
|
||||
})),
|
||||
msg,
|
||||
),
|
||||
),
|
||||
);
|
||||
const slots = inputs.flatMap((b) =>
|
||||
(b.input?.elements ?? [])
|
||||
.filter((e) => e.mode === "choice")
|
||||
.map((e) => slotRow(b, e, { ...on, change: (n) => safe(() => n) }, lg)),
|
||||
);
|
||||
const rules = inputs.flatMap((b) =>
|
||||
(b.input?.elements ?? [])
|
||||
.filter((e) => e.mode === "rule" && e.rule)
|
||||
.map((e) => ruleRow(e, lg, result)),
|
||||
);
|
||||
const logics = inputs.flatMap((b) =>
|
||||
(b.input?.logic?.designs ?? [])
|
||||
.filter((d) => !lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
|
||||
.map((d) =>
|
||||
el("li", {
|
||||
children: [
|
||||
createButton({
|
||||
label: `${b.name} · ${d.name}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !on.select,
|
||||
onClick: () => on.select?.({ box: b.id, port: d.name }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] =>
|
||||
body.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfver__group",
|
||||
children: [el("strong", { text: tv(title) }), ...body],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const design = [
|
||||
...group(
|
||||
"Vars",
|
||||
[el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length),
|
||||
),
|
||||
...group("Slots", slots),
|
||||
...group("Rules", rules),
|
||||
...group(
|
||||
"Logics",
|
||||
logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [],
|
||||
),
|
||||
];
|
||||
host.replaceChildren(
|
||||
...spotBlock,
|
||||
...(design.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfver__block",
|
||||
children: [
|
||||
el("strong", { className: "m02-dfver__title", text: tv("Design") }),
|
||||
...design,
|
||||
el("p", { className: "m02-dfver__muted", text: tv("DesignHint") }),
|
||||
msg,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"key": "SAMPLE-ground",
|
||||
"name": "견본 · 지반에 따라 굴삭기 / 브레이커",
|
||||
"resultUnit": "원/㎥",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-ground",
|
||||
"kind": "input",
|
||||
"name": "지반 · 굴착량",
|
||||
"color": "#2f80ed",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 60,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "지반",
|
||||
"type": "choice",
|
||||
"unit": "-",
|
||||
"example": "토사",
|
||||
"choices": ["토사", "암"]
|
||||
},
|
||||
{
|
||||
"name": "굴착량",
|
||||
"type": "number",
|
||||
"unit": "㎥",
|
||||
"example": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "in-cost",
|
||||
"kind": "input",
|
||||
"name": "장비 시간당 경비",
|
||||
"color": "#2f80ed",
|
||||
"unit": "원/시간",
|
||||
"x": 40,
|
||||
"y": 300,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "굴삭기 시간당 경비",
|
||||
"type": "number",
|
||||
"unit": "원/시간",
|
||||
"example": 60000
|
||||
},
|
||||
{
|
||||
"name": "브레이커 시간당 경비",
|
||||
"type": "number",
|
||||
"unit": "원/시간",
|
||||
"example": 80000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-exc",
|
||||
"kind": "function",
|
||||
"name": "굴삭기로 파기",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "원",
|
||||
"x": 380,
|
||||
"y": 40,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "s-fn-exc",
|
||||
"이름": "굴삭기로 파기",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "굴착량"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 지반이 토사일 때만 옴"
|
||||
},
|
||||
"A3": {
|
||||
"값": "시간당 경비"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 원/시간"
|
||||
},
|
||||
"A4": {
|
||||
"값": "시간당 작업량"
|
||||
},
|
||||
"B4": {
|
||||
"값": 30
|
||||
},
|
||||
"C4": {
|
||||
"값": "㎥/시간 · 고침"
|
||||
},
|
||||
"A5": {
|
||||
"값": "작업 시간"
|
||||
},
|
||||
"B5": {
|
||||
"식": "B2/B4"
|
||||
},
|
||||
"C5": {
|
||||
"값": "시간"
|
||||
},
|
||||
"A6": {
|
||||
"값": "굴삭기 경비"
|
||||
},
|
||||
"B6": {
|
||||
"식": "B5*B3"
|
||||
},
|
||||
"C6": {
|
||||
"값": "원"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "굴착량",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "시간당 경비",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B6",
|
||||
"name": "굴삭기 경비",
|
||||
"unit": "원"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-brk",
|
||||
"kind": "function",
|
||||
"name": "브레이커로 깨기",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "원",
|
||||
"x": 380,
|
||||
"y": 300,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "s-fn-brk",
|
||||
"이름": "브레이커로 깨기",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "굴착량"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 지반이 토사가 아닐 때만 옴"
|
||||
},
|
||||
"A3": {
|
||||
"값": "시간당 경비"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 원/시간"
|
||||
},
|
||||
"A4": {
|
||||
"값": "시간당 작업량"
|
||||
},
|
||||
"B4": {
|
||||
"값": 8
|
||||
},
|
||||
"C4": {
|
||||
"값": "㎥/시간 · 고침"
|
||||
},
|
||||
"A5": {
|
||||
"값": "작업 시간"
|
||||
},
|
||||
"B5": {
|
||||
"식": "B2/B4"
|
||||
},
|
||||
"C5": {
|
||||
"값": "시간"
|
||||
},
|
||||
"A6": {
|
||||
"값": "브레이커 경비"
|
||||
},
|
||||
"B6": {
|
||||
"식": "B5*B3"
|
||||
},
|
||||
"C6": {
|
||||
"값": "원"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "굴착량",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "시간당 경비",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B6",
|
||||
"name": "브레이커 경비",
|
||||
"unit": "원"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-sum",
|
||||
"kind": "function",
|
||||
"name": "1㎥ 경비",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "원/㎥",
|
||||
"x": 720,
|
||||
"y": 170,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "s-fn-sum",
|
||||
"이름": "1㎥ 경비",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "굴삭기 경비"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 안 오면 빈 칸 = 0"
|
||||
},
|
||||
"A3": {
|
||||
"값": "브레이커 경비"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 안 오면 빈 칸 = 0"
|
||||
},
|
||||
"A4": {
|
||||
"값": "굴착량"
|
||||
},
|
||||
"C4": {
|
||||
"값": "받는 값 · ㎥"
|
||||
},
|
||||
"A5": {
|
||||
"값": "1㎥ 경비"
|
||||
},
|
||||
"B5": {
|
||||
"식": "(B2+B3)/B4"
|
||||
},
|
||||
"C5": {
|
||||
"값": "둘 중 온 것 ÷ 굴착량"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "굴삭기 경비",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "브레이커 경비",
|
||||
"cell": "B3"
|
||||
},
|
||||
{
|
||||
"port": "굴착량",
|
||||
"cell": "B4"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B5",
|
||||
"name": "1㎥ 경비",
|
||||
"unit": "원/㎥"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "out-exp",
|
||||
"kind": "output",
|
||||
"name": "경비",
|
||||
"color": "#27ae60",
|
||||
"unit": "원/㎥",
|
||||
"x": 1040,
|
||||
"y": 170,
|
||||
"output": {
|
||||
"cost": "경비",
|
||||
"unit": "원/㎥",
|
||||
"round": {
|
||||
"places": 1,
|
||||
"method": "버림"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": {
|
||||
"box": "in-ground",
|
||||
"name": "굴착량"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-exc",
|
||||
"name": "굴착량"
|
||||
},
|
||||
"rule": {
|
||||
"kind": "gate",
|
||||
"from": {
|
||||
"box": "in-ground",
|
||||
"name": "지반"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "토사"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": {
|
||||
"box": "in-ground",
|
||||
"name": "굴착량"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-brk",
|
||||
"name": "굴착량"
|
||||
},
|
||||
"rule": {
|
||||
"kind": "gate",
|
||||
"from": {
|
||||
"box": "in-ground",
|
||||
"name": "지반"
|
||||
},
|
||||
"op": "≠",
|
||||
"value": "토사"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"from": {
|
||||
"box": "in-cost",
|
||||
"name": "굴삭기 시간당 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-exc",
|
||||
"name": "시간당 경비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"from": {
|
||||
"box": "in-cost",
|
||||
"name": "브레이커 시간당 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-brk",
|
||||
"name": "시간당 경비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l5",
|
||||
"from": {
|
||||
"box": "fn-exc",
|
||||
"name": "굴삭기 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-sum",
|
||||
"name": "굴삭기 경비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l6",
|
||||
"from": {
|
||||
"box": "fn-brk",
|
||||
"name": "브레이커 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-sum",
|
||||
"name": "브레이커 경비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l7",
|
||||
"from": {
|
||||
"box": "in-ground",
|
||||
"name": "굴착량"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-sum",
|
||||
"name": "굴착량"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l8",
|
||||
"from": {
|
||||
"box": "fn-sum",
|
||||
"name": "1㎥ 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "out-exp",
|
||||
"name": "값"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userElements": []
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"key": "SAMPLE-hume",
|
||||
"name": "견본 · 흄관 관경 조건 찾기",
|
||||
"resultUnit": "원/m",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-size",
|
||||
"kind": "input",
|
||||
"name": "관경 · 연장",
|
||||
"color": "#2f80ed",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 60,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "관경",
|
||||
"type": "choice",
|
||||
"unit": "mm",
|
||||
"example": 600,
|
||||
"choices": [400, 450, 500, 600, 700, 800, 900, 1000, 1100, 1200]
|
||||
},
|
||||
{
|
||||
"name": "연장",
|
||||
"type": "number",
|
||||
"unit": "m",
|
||||
"example": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "in-pipe",
|
||||
"kind": "input",
|
||||
"name": "흄관",
|
||||
"color": "#2f80ed",
|
||||
"unit": "본",
|
||||
"x": 40,
|
||||
"y": 300,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "재료",
|
||||
"variables": [],
|
||||
"elements": [
|
||||
{
|
||||
"ref": "MT014991",
|
||||
"name": "흄관",
|
||||
"spec": "Φ600mm",
|
||||
"unit": "본",
|
||||
"mode": "rule",
|
||||
"scope": {
|
||||
"구분": "관·이음관",
|
||||
"상세구분": "흄관",
|
||||
"찾는말": "접속흄관"
|
||||
},
|
||||
"rule": {
|
||||
"field": "규격",
|
||||
"op": "포함",
|
||||
"from": {
|
||||
"box": "in-size",
|
||||
"name": "관경"
|
||||
}
|
||||
},
|
||||
"default": "MT014991",
|
||||
"slot": "흄관"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-pipe",
|
||||
"kind": "function",
|
||||
"name": "흄관 재료비",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "원/m",
|
||||
"x": 380,
|
||||
"y": 160,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "s-fn-pipe",
|
||||
"이름": "흄관 재료비",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "연장"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · m"
|
||||
},
|
||||
"A3": {
|
||||
"값": "흄관 단가"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 원/본(관경으로 찾은 품목)"
|
||||
},
|
||||
"A4": {
|
||||
"값": "1본 길이"
|
||||
},
|
||||
"B4": {
|
||||
"값": 2.5
|
||||
},
|
||||
"C4": {
|
||||
"값": "m · 고침"
|
||||
},
|
||||
"A5": {
|
||||
"값": "본수"
|
||||
},
|
||||
"B5": {
|
||||
"식": "ROUNDUP(B2/B4,0)"
|
||||
},
|
||||
"C5": {
|
||||
"값": "올림"
|
||||
},
|
||||
"A6": {
|
||||
"값": "1m 재료비"
|
||||
},
|
||||
"B6": {
|
||||
"식": "B5*B3/B2"
|
||||
},
|
||||
"C6": {
|
||||
"값": "본수 × 단가 ÷ 연장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "연장",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "흄관 단가",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B6",
|
||||
"name": "1m 재료비",
|
||||
"unit": "원/m"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "out-mat",
|
||||
"kind": "output",
|
||||
"name": "재료비",
|
||||
"color": "#27ae60",
|
||||
"unit": "원/m",
|
||||
"x": 720,
|
||||
"y": 160,
|
||||
"output": {
|
||||
"cost": "재료비",
|
||||
"unit": "원/m",
|
||||
"round": {
|
||||
"places": 1,
|
||||
"method": "버림"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": {
|
||||
"box": "in-size",
|
||||
"name": "연장"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-pipe",
|
||||
"name": "연장"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": {
|
||||
"box": "in-pipe",
|
||||
"name": "흄관"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-pipe",
|
||||
"name": "흄관 단가"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"from": {
|
||||
"box": "fn-pipe",
|
||||
"name": "1m 재료비"
|
||||
},
|
||||
"to": {
|
||||
"box": "out-mat",
|
||||
"name": "값"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userElements": []
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"key": "SAMPLE-linked",
|
||||
"name": "견본 · 다른 로직 잇기(작업량 → 장비 시간)",
|
||||
"resultUnit": "원/㎥",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-logic",
|
||||
"kind": "input",
|
||||
"name": "덤프트럭 시간당 작업량",
|
||||
"color": "#2f80ed",
|
||||
"unit": "㎥/시간",
|
||||
"x": 40,
|
||||
"y": 60,
|
||||
"input": {
|
||||
"mode": "logic",
|
||||
"elements": [],
|
||||
"variables": [],
|
||||
"logic": {
|
||||
"key": "value",
|
||||
"name": "견본 · 덤프트럭 시간당 작업량(값)",
|
||||
"outputs": [
|
||||
{
|
||||
"name": "시간당 작업량",
|
||||
"unit": "㎥/시간"
|
||||
}
|
||||
],
|
||||
"designs": [
|
||||
{
|
||||
"name": "q",
|
||||
"unit": "㎥"
|
||||
},
|
||||
{
|
||||
"name": "f",
|
||||
"unit": "-"
|
||||
},
|
||||
{
|
||||
"name": "E",
|
||||
"unit": "-"
|
||||
},
|
||||
{
|
||||
"name": "Cm",
|
||||
"unit": "분"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "in-var",
|
||||
"kind": "input",
|
||||
"name": "운반량 · 경비",
|
||||
"color": "#2f80ed",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 300,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "운반량",
|
||||
"type": "number",
|
||||
"unit": "㎥",
|
||||
"example": 120
|
||||
},
|
||||
{
|
||||
"name": "덤프트럭 시간당 경비",
|
||||
"type": "number",
|
||||
"unit": "원/시간",
|
||||
"example": 50000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-hours",
|
||||
"kind": "function",
|
||||
"name": "운반 시간",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "시간",
|
||||
"x": 380,
|
||||
"y": 60,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "s-fn-hours",
|
||||
"이름": "운반 시간",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "시간당 작업량"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 다른 로직 출력"
|
||||
},
|
||||
"A3": {
|
||||
"값": "운반량"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · ㎥"
|
||||
},
|
||||
"A4": {
|
||||
"값": "운반 시간"
|
||||
},
|
||||
"B4": {
|
||||
"식": "B3/B2"
|
||||
},
|
||||
"C4": {
|
||||
"값": "운반량 ÷ 시간당 작업량"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "시간당 작업량",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "운반량",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B4",
|
||||
"name": "운반 시간",
|
||||
"unit": "시간",
|
||||
"round": {
|
||||
"places": 2,
|
||||
"method": "반올림"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-cost",
|
||||
"kind": "function",
|
||||
"name": "1㎥ 운반 경비",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "원/㎥",
|
||||
"x": 700,
|
||||
"y": 180,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "s-fn-cost",
|
||||
"이름": "1㎥ 운반 경비",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "운반 시간"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 시간"
|
||||
},
|
||||
"A3": {
|
||||
"값": "시간당 경비"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 원/시간"
|
||||
},
|
||||
"A4": {
|
||||
"값": "운반량"
|
||||
},
|
||||
"C4": {
|
||||
"값": "받는 값 · ㎥"
|
||||
},
|
||||
"A5": {
|
||||
"값": "1㎥ 운반 경비"
|
||||
},
|
||||
"B5": {
|
||||
"식": "B2*B3/B4"
|
||||
},
|
||||
"C5": {
|
||||
"값": "시간 × 경비 ÷ 운반량"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "운반 시간",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "시간당 경비",
|
||||
"cell": "B3"
|
||||
},
|
||||
{
|
||||
"port": "운반량",
|
||||
"cell": "B4"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B5",
|
||||
"name": "1㎥ 운반 경비",
|
||||
"unit": "원/㎥"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "out-exp",
|
||||
"kind": "output",
|
||||
"name": "경비",
|
||||
"color": "#27ae60",
|
||||
"unit": "원/㎥",
|
||||
"x": 1020,
|
||||
"y": 180,
|
||||
"output": {
|
||||
"cost": "경비",
|
||||
"unit": "원/㎥",
|
||||
"round": {
|
||||
"places": 1,
|
||||
"method": "버림"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": {
|
||||
"box": "in-logic",
|
||||
"name": "시간당 작업량"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-hours",
|
||||
"name": "시간당 작업량"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "운반량"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-hours",
|
||||
"name": "운반량"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"from": {
|
||||
"box": "fn-hours",
|
||||
"name": "운반 시간"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-cost",
|
||||
"name": "운반 시간"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "덤프트럭 시간당 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-cost",
|
||||
"name": "시간당 경비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l5",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "운반량"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-cost",
|
||||
"name": "운반량"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l6",
|
||||
"from": {
|
||||
"box": "fn-cost",
|
||||
"name": "1㎥ 운반 경비"
|
||||
},
|
||||
"to": {
|
||||
"box": "out-exp",
|
||||
"name": "값"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userElements": []
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"key": "SAMPLE-rate",
|
||||
"name": "견본 · 공구손료(비율)",
|
||||
"resultUnit": "원",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-var",
|
||||
"kind": "input",
|
||||
"name": "바탕 값",
|
||||
"color": "#2f80ed",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 80,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "노무비",
|
||||
"type": "number",
|
||||
"unit": "원",
|
||||
"example": 100000
|
||||
},
|
||||
{
|
||||
"name": "공구손료율",
|
||||
"type": "number",
|
||||
"unit": "%",
|
||||
"example": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-rate",
|
||||
"kind": "function",
|
||||
"name": "공구손료",
|
||||
"color": "#7b4fd6",
|
||||
"unit": "원",
|
||||
"x": 340,
|
||||
"y": 80,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-tool",
|
||||
"이름": "공구손료",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "노무비"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 원"
|
||||
},
|
||||
"A3": {
|
||||
"값": "공구손료율"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · %"
|
||||
},
|
||||
"A4": {
|
||||
"값": "공구손료"
|
||||
},
|
||||
"B4": {
|
||||
"식": "B2*B3/100"
|
||||
},
|
||||
"C4": {
|
||||
"값": "노무비 × 율 ÷ 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "노무비",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "공구손료율",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B4",
|
||||
"name": "공구손료",
|
||||
"unit": "원"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "out-mat",
|
||||
"kind": "output",
|
||||
"name": "재료비",
|
||||
"color": "#27ae60",
|
||||
"unit": "원",
|
||||
"x": 640,
|
||||
"y": 80,
|
||||
"output": {
|
||||
"cost": "재료비",
|
||||
"unit": "원"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "노무비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-rate",
|
||||
"name": "노무비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "공구손료율"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-rate",
|
||||
"name": "공구손료율"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"from": {
|
||||
"box": "fn-rate",
|
||||
"name": "공구손료"
|
||||
},
|
||||
"to": {
|
||||
"box": "out-mat",
|
||||
"name": "값"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userElements": []
|
||||
}
|
||||
@@ -643,6 +643,11 @@ app.include_router(
|
||||
__import__("M02_MasterTemplete.CostFlow.M02_CF_Router", fromlist=["router"]).router,
|
||||
dependencies=system_admin_only,
|
||||
)
|
||||
# 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
|
||||
dependencies=system_admin_only,
|
||||
)
|
||||
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
|
||||
app.include_router(m02_master_template_router, dependencies=system_admin_only)
|
||||
app.include_router(m02_layers_router, dependencies=protected)
|
||||
|
||||
@@ -346,3 +346,249 @@ def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호
|
||||
["결과 4", "결과 4"],
|
||||
]
|
||||
assert ports == ["결과 1", "결과 2", "결과 1 (2)"]
|
||||
|
||||
|
||||
# ── 흐름도 넓힘(35-9) — 견본 셋 ─────────────────────────────────────────────
|
||||
RULE = r"""
|
||||
const R = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts"));
|
||||
const rjoin = (lg, a, ap, b, bp, rule) => {
|
||||
const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }, rule);
|
||||
if (r.why) throw new Error(r.why);
|
||||
return r.lg;
|
||||
};
|
||||
const vars = (lg, box, list) => M.updateBox(lg, box.id, { input: { mode: "variable", elements: [], variables: list } });
|
||||
const num = (name, example, unit = "-") => ({ name, type: "number", unit, example });
|
||||
"""
|
||||
|
||||
# 견본 가 — 재료 조건 찾기: 관경(변수) → 흄관(규격에 관경 포함) 단가 · 수량 × 단가 → 재료비 · 거푸집 = 선택 자리
|
||||
PIPE = (
|
||||
RULE
|
||||
+ r"""
|
||||
let lg = M.newLogic("흄관 조건 찾기");
|
||||
let r = add(lg, "input"); lg = r.lg; const dia = r.box;
|
||||
lg = vars(lg, dia, [num("관경", 300, "㎜"), num("길이", 12, "m")]);
|
||||
r = add(lg, "input"); lg = r.lg; const mat = r.box;
|
||||
lg = M.updateBox(lg, mat.id, { input: { mode: "group", group: "재료", variables: [], elements: [
|
||||
{ ref: "", name: "흄관", spec: "", unit: "m", mode: "rule", slot: "흄관 단가", scope: { 구분: "관·수로", 상세구분: "흄관" },
|
||||
rule: { field: "규격", op: "포함", from: { box: dia.id, name: "관경" } } },
|
||||
{ ref: "", name: "거푸집", spec: "", unit: "㎡", mode: "choice", slot: "거푸집 자리", default: "MT000123" } ] } });
|
||||
r = add(lg, "function"); lg = r.lg; const f = r.box;
|
||||
lg = func(lg, f, { B9: { 식: "B1*B2" } }, [{ port: "길이", cell: "B1" }, { port: "흄관 단가", cell: "B2" }], { cell: "B9", name: "재료값" });
|
||||
r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "재료비", unit: "원" } });
|
||||
lg = join(lg, dia, "길이", f, "길이");
|
||||
lg = join(lg, mat, "흄관 단가", f, "흄관 단가");
|
||||
lg = join(lg, f, "재료값", o, "값");
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_가_재료_조건_찾기_흐름() -> None:
|
||||
got = _run(
|
||||
PIPE + "const ok = E.evalLogic(lg, { [mat.id]: { '흄관 단가': 52000 } });\n"
|
||||
"const none = E.breaks(lg, {});\n"
|
||||
"out([M.portsOut(lg.boxes[1]).map(p => p.name), ok.cost.재료비, ok.arrived[f.id], none,"
|
||||
" M.traceBack(lg, { box: o.id, name: '값' }), M.designInputs(lg).map(d => [d.kind, d.port]),"
|
||||
" M.reaches(lg, dia.id, f.id)]);"
|
||||
)
|
||||
pills, cost, arrived, none, back, designs, dep = got
|
||||
assert pills == ["흄관 단가", "거푸집 자리"] # 재료 자리 = 알약 이름
|
||||
assert cost == 12 * 52000 and arrived == {"길이": 12, "흄관 단가": 52000}
|
||||
assert [(b["kind"], b["port"]) for b in none] == [("맞는 재료 없음", "흄관 단가")]
|
||||
assert none[0]["text"] == "「입력 2」 의 「흄관 단가」 — 조건에 맞는 재료가 없음"
|
||||
# 출력 값 ← 함수 재료값 ← 길이 · 흄관 단가 ← (조건) 관경
|
||||
assert [(s["box"], s["port"], s["dir"]) for s in back] == [
|
||||
("b4", "값", "in"),
|
||||
("b3", "재료값", "out"),
|
||||
("b3", "길이", "in"),
|
||||
("b1", "길이", "out"),
|
||||
("b3", "흄관 단가", "in"),
|
||||
("b2", "흄관 단가", "out"),
|
||||
("b1", "관경", "out"),
|
||||
]
|
||||
assert designs == [["변수", "관경"], ["변수", "길이"], ["재료 자리", "거푸집 자리"]]
|
||||
assert dep is True # 관경 → (재료 조건) → 함수
|
||||
|
||||
|
||||
# 견본 나 — 조건 통과로 둘로 가르기(지반 토사 · 암) + 표에서 찾기 + 바꾸기 · 끝수
|
||||
SPLIT = (
|
||||
RULE
|
||||
+ r"""
|
||||
let lg = M.newLogic("지반 가르기");
|
||||
let r = add(lg, "input"); lg = r.lg; const v = r.box;
|
||||
lg = vars(lg, v, [{ name: "지반", type: "choice", unit: "-", choices: ["토사", "암"], example: "토사" },
|
||||
num("높이", 4.5, "m"), num("품", 0.5, "인")]);
|
||||
r = add(lg, "input"); lg = r.lg; const t = r.box;
|
||||
lg = M.updateBox(lg, t.id, { input: { mode: "group", group: "소요량", elements: [], variables: [], table: { key: "QF000422" } } });
|
||||
const fn = (out) => { r = add(lg, "function"); lg = r.lg; const b = r.box;
|
||||
lg = func(lg, b, { B9: { 식: "B1*100000" } }, [{ port: "가", cell: "B1" }], { cell: "B9", name: out }); return b; };
|
||||
const soil = fn("토사 노임"), rock = fn("암 노임");
|
||||
r = add(lg, "function"); lg = r.lg; const rate = r.box;
|
||||
lg = func(lg, rate, { B9: { 식: "B1" } }, [{ port: "증가율", cell: "B1" }], { cell: "B9", name: "증가율 값" });
|
||||
r = add(lg, "output"); lg = r.lg; const o1 = r.box;
|
||||
r = add(lg, "output"); lg = r.lg; const o2 = r.box;
|
||||
r = add(lg, "output"); lg = r.lg; const o3 = r.box; lg = M.updateBox(lg, o3.id, { output: { cost: "값", unit: "%" } });
|
||||
const gate = (value, op = "=") => ({ kind: "gate", from: { box: v.id, name: "지반" }, op, value });
|
||||
lg = rjoin(lg, v, "품", soil, "가", gate("토사"));
|
||||
lg = rjoin(lg, v, "품", rock, "가", gate("암"));
|
||||
lg = rjoin(lg, t, "표", rate, "증가율", { kind: "lookup", conds: [{ col: "높이", from: { box: v.id, name: "높이" } }], col: "증가율" });
|
||||
lg = rjoin(lg, soil, "토사 노임", o1, "값", { kind: "scale", op: "+%", value: 10 });
|
||||
lg = rjoin(lg, rock, "암 노임", o2, "값", { kind: "round", places: -3, method: "버림" });
|
||||
lg = join(lg, rate, "증가율 값", o3, "값");
|
||||
const TABLE = [["높이·아래", "높이·위", "증가율"], [3, 4, 30], [4, 5.5, 40], [5.5, 7.5, 60]];
|
||||
const V = (soilKind, h = 4.5) => ({ [v.id]: { 지반: soilKind, 높이: h }, [t.id]: { 표: TABLE } });
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_나_조건_통과로_둘로_가르기_표에서_찾기() -> None:
|
||||
got = _run(
|
||||
SPLIT + "const a = E.evalLogic(lg, V('토사'));\n"
|
||||
"const b = E.evalLogic(lg, V('암', 3.5));\n"
|
||||
"out([a.outputs, a.breaks.map(x => [x.kind, x.link]), b.outputs, b.breaks.map(x => x.kind),"
|
||||
" E.breaks(lg, V('토사', 9)).map(x => [x.kind, x.text])]);"
|
||||
)
|
||||
a_out, a_breaks, b_out, b_breaks, far = got
|
||||
# 토사: 0.5 × 100000 × 1.1 · 암 줄은 통과 안 됨 · 높이 4.5 → 증가율 40
|
||||
assert a_out == {"b6": 55000, "b7": None, "b8": 40}
|
||||
assert a_breaks == [["통과 안 됨", "l2"]]
|
||||
assert b_out == {
|
||||
"b6": None,
|
||||
"b7": 50000,
|
||||
"b8": 30,
|
||||
} # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30
|
||||
assert b_breaks == ["통과 안 됨"]
|
||||
assert far == [
|
||||
["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"],
|
||||
["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"],
|
||||
]
|
||||
|
||||
|
||||
def test_나_조건_선_겹침_안맞음() -> None:
|
||||
got = _run(
|
||||
SPLIT + "const l2 = lg.links[1].id;\n"
|
||||
"const overlap = M.setRule(lg, l2, { kind: 'gate', from: { box: v.id, name: '지반' }, op: '≠', value: '암' }).lg;\n"
|
||||
"out([E.breaks(overlap, V('토사')).map(x => [x.kind, x.text]), E.breaks(lg, V('자갈')).map(x => x.kind)]);"
|
||||
)
|
||||
overlap, none = got
|
||||
assert overlap == [["조건 겹침", "「입력」 의 「지반」 에서 나간 조건 선이 둘 이상 통과함"]]
|
||||
assert none == ["통과 안 됨", "통과 안 됨", "조건 안 맞음"]
|
||||
|
||||
|
||||
def test_나_규칙_글과_엑셀_수식() -> None:
|
||||
got = _run(
|
||||
SPLIT + "out([lg.links.map(l => l.rule ? R.ruleLabel(l.rule) : null),"
|
||||
" lg.links.map(l => l.rule ? R.applyRule(l.rule, 1000, { 지반: '토사', 높이: 4.5 }).formula : null).slice(0, 2),"
|
||||
" R.applyRule({ kind: 'scale', op: '+%', value: 10 }, 200).formula, R.applyRule({ kind: 'scale', op: '÷', value: 4 }, 10).value,"
|
||||
" R.applyRule({ kind: 'round', places: 1, method: '올림' }, 1.21).value, R.applyRound({ places: 0, method: '버림' }, 9.9)]);"
|
||||
)
|
||||
labels, gates, scale_f, div, up, down = got
|
||||
assert labels == [
|
||||
"지반 = 토사 일 때만",
|
||||
"지반 = 암 일 때만",
|
||||
"표에서 찾기 (높이 = 높이) → 증가율",
|
||||
"+10%",
|
||||
"소수 -3자리 버림",
|
||||
None,
|
||||
]
|
||||
assert gates == ['IF(C1="토사",A1,NA())', 'IF(C1="암",A1,NA())']
|
||||
assert scale_f == "A1*(1+10/100)" and div == 2.5 and up == 1.3 and down == 9
|
||||
|
||||
|
||||
def test_나_흐름_따라가기_앞으로() -> None:
|
||||
got = _run(
|
||||
SPLIT
|
||||
+ "out(M.traceForward(lg, { box: v.id, name: '지반' }).map(s => [s.box, s.port, s.dir, s.rule ? s.rule.kind : null]));"
|
||||
)
|
||||
assert got == [
|
||||
["b1", "지반", "out", None],
|
||||
["b3", "가", "in", "gate"],
|
||||
["b3", "토사 노임", "out", None],
|
||||
["b6", "값", "in", "scale"],
|
||||
["b4", "가", "in", "gate"],
|
||||
["b4", "암 노임", "out", None],
|
||||
["b7", "값", "in", "round"],
|
||||
]
|
||||
|
||||
|
||||
def test_규칙_조건_알약_없으면_막음_순환() -> None:
|
||||
got = _run(
|
||||
SPLIT
|
||||
+ "const bad = M.link(lg, { box: v.id, name: '품' }, { box: rate.id, name: '' }, { kind: 'gate', from: { box: v.id, name: '없음' }, op: '=', value: 1 }).why;\n"
|
||||
"const loop = M.setRule(lg, lg.links[0].id, { kind: 'gate', from: { box: soil.id, name: '토사 노임' }, op: '>', value: 0 }).why;\n"
|
||||
"const off = M.setRule(lg, lg.links[3].id, null).lg.links[3];\n"
|
||||
"out([bad, loop, off]);"
|
||||
)
|
||||
bad, loop, off = got
|
||||
assert bad == "규칙의 조건 알약이 없음"
|
||||
assert loop == "이으면 돌고 도는 선이 됨"
|
||||
assert "rule" not in off
|
||||
|
||||
|
||||
# 견본 다 — 다른 로직 잇기: 안쪽 로직(면적 × 계수 = 작업량 값) · 바깥 로직이 길이를 면적으로 넘기고 작업량을 받아 경비
|
||||
LOGIC = (
|
||||
RULE
|
||||
+ r"""
|
||||
let inner = M.newLogic("작업량");
|
||||
let r = add(inner, "input"); inner = r.lg; const ia = r.box;
|
||||
inner = vars(inner, ia, [num("면적", 1, "㎡"), num("계수", 3)]);
|
||||
r = add(inner, "function"); inner = r.lg; const ifn = r.box;
|
||||
inner = func(inner, ifn, { B9: { 식: "B1*B2" } }, [{ port: "면적", cell: "B1" }, { port: "계수", cell: "B2" }], { cell: "B9", name: "작업량 값" });
|
||||
r = add(inner, "output"); inner = r.lg; const io = r.box;
|
||||
inner = M.updateBox(inner, io.id, { name: "작업량", output: { cost: "값", unit: "㎥/시간" } });
|
||||
inner = join(inner, ia, "면적", ifn, "면적");
|
||||
inner = join(inner, ia, "계수", ifn, "계수");
|
||||
inner = join(inner, ifn, "작업량 값", inner.boxes.find(b => b.id === io.id), "값");
|
||||
let lg = M.newLogic("바깥");
|
||||
r = add(lg, "input"); lg = r.lg; const len = r.box; lg = vars(lg, len, [num("길이", 7, "m")]);
|
||||
r = add(lg, "input"); lg = r.lg; const other = r.box;
|
||||
lg = M.updateBox(lg, other.id, { input: { mode: "logic", elements: [], variables: [],
|
||||
logic: { key: "DF-0001", name: "작업량", outputs: [{ name: "작업량", unit: "㎥/시간" }],
|
||||
designs: M.designInputs(inner).map(d => ({ name: d.port, unit: d.unit })) } } });
|
||||
r = add(lg, "function"); lg = r.lg; const f = r.box;
|
||||
lg = func(lg, f, { B9: { 식: "B1*1000" } }, [{ port: "작업량", cell: "B1" }], { cell: "B9", name: "경비 값" });
|
||||
r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "경비", unit: "원" } });
|
||||
lg = join(lg, len, "길이", other, "면적");
|
||||
lg = join(lg, other, "작업량", f, "작업량");
|
||||
lg = join(lg, f, "경비 값", o, "값");
|
||||
const LOGICS = { "DF-0001": inner };
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_다_다른_로직_잇기() -> None:
|
||||
got = _run(
|
||||
LOGIC + "const a = E.evalLogic(lg, {}, { logics: LOGICS });\n"
|
||||
"const b = E.evalLogic(lg, { [len.id]: { 길이: 2 }, [other.id]: { 계수: 10 } }, { logics: LOGICS });\n"
|
||||
"const c = E.evalLogic(lg, { [other.id]: { 작업량: 5 } });\n"
|
||||
"out([M.portsIn(lg.boxes[1]).map(p => p.name), M.portsOut(lg.boxes[1]).map(p => p.name), a.ports[other.id], a.cost.경비,"
|
||||
" b.cost.경비, c.cost.경비, M.designInputs(lg).map(d => [d.kind, d.port]),"
|
||||
" M.link(lg, { box: len.id, name: '길이' }, { box: other.id, name: '없음' }).why,"
|
||||
" M.traceBack(lg, { box: o.id, name: '값' }).map(s => [s.box, s.port]), E.evalLogic(lg, {}, { logics: {} }).errors]);"
|
||||
)
|
||||
ins, outs, ports, a, b, c, designs, no_pill, back, missing = got
|
||||
assert ins == ["면적", "계수"] and outs == ["작업량"]
|
||||
assert ports == {"작업량": 21} # 길이 7 → 면적 · 계수 예시값 3
|
||||
assert (a, b, c) == (21000, 20000, 5000)
|
||||
assert designs == [["변수", "길이"], ["다른 로직", "계수"]] # 면적은 선이 있어 빠짐
|
||||
assert no_pill == "알약이 없음"
|
||||
assert back == [
|
||||
["b4", "값"],
|
||||
["b3", "경비 값"],
|
||||
["b3", "작업량"],
|
||||
["b2", "작업량"],
|
||||
["b2", "면적"],
|
||||
["b1", "길이"],
|
||||
["b2", "계수"],
|
||||
]
|
||||
assert missing == ["「입력 2」 이 부르는 로직을 찾을 수 없음"]
|
||||
|
||||
|
||||
def test_끝수_함수_출력_출력_상자() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, output: { ...lg.boxes[2].func.output, round: { places: -3, method: '버림' } } } });\n"
|
||||
"const a = E.evalLogic(lg, V).cost.노무비;\n"
|
||||
"lg = M.updateBox(lg, o.id, { output: { cost: '노무비', unit: '원', round: { places: -4, method: '올림' } } });\n"
|
||||
"out([a, E.evalLogic(lg, V).cost.노무비]);"
|
||||
)
|
||||
assert got == [537000, 540000]
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
|
||||
"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
|
||||
|
||||
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
|
||||
(Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만
|
||||
· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100
|
||||
견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value)
|
||||
· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
· 선 규칙(조건 통과 · 끝수) · 재료 방식(조건 찾기) · 다른 로직 입력의 알약이 있는 자리를 가리킴
|
||||
· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음
|
||||
· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 ·
|
||||
입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133
|
||||
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -22,6 +24,15 @@ 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
|
||||
NAMES = ["masonry", "ground", "hume", "linked", "value"]
|
||||
BOX_COLOR = dict(
|
||||
re.findall(
|
||||
r'(\w+): "(#[0-9a-fA-F]{6})"',
|
||||
re.search(
|
||||
r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8")
|
||||
).group(1),
|
||||
)
|
||||
)
|
||||
|
||||
HOOKS = r"""
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -58,13 +69,15 @@ def _node(body: str) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
# ── 모양 ─────────────────────────────────────────────────────────────────
|
||||
# ── 알약 · 선 ─────────────────────────────────────────────────────────────
|
||||
def _ports_out(box: dict) -> list[str]:
|
||||
if box["kind"] == "function":
|
||||
return [box["func"]["output"]["name"]]
|
||||
if box["kind"] == "output":
|
||||
return []
|
||||
inp = box["input"]
|
||||
if inp["mode"] == "logic":
|
||||
return [p["name"] for p in inp["logic"]["outputs"]]
|
||||
return (
|
||||
[e["name"] for e in inp.get("elements", [])]
|
||||
+ (["표"] if inp.get("table") else [])
|
||||
@@ -78,10 +91,28 @@ def _ports_in(box: dict) -> list[str]:
|
||||
return ["값"] if box["kind"] == "output" else []
|
||||
|
||||
|
||||
def _deps(lg: dict, box_id: str) -> list[dict]:
|
||||
"""그 상자가 기대는 알약 — 들어온 선 · 그 선 규칙의 조건 알약 · 재료 조건 알약"""
|
||||
out = []
|
||||
for link in lg["links"]:
|
||||
if link["to"]["box"] != box_id:
|
||||
continue
|
||||
out.append(link["from"])
|
||||
rule = link.get("rule") or {}
|
||||
if rule.get("kind") == "gate":
|
||||
out.append(rule["from"])
|
||||
out += [c["from"] for c in rule.get("conds", [])]
|
||||
box = next(b for b in lg["boxes"] if b["id"] == box_id)
|
||||
for e in (box.get("input") or {}).get("elements", []):
|
||||
if e.get("rule"):
|
||||
out.append(e["rule"]["from"])
|
||||
return out
|
||||
|
||||
|
||||
def _order(lg: dict) -> list[dict]:
|
||||
"""함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패"""
|
||||
"""상자 차례(기대는 것이 앞) — 순환이면 실패"""
|
||||
boxes = {b["id"]: b for b in lg["boxes"]}
|
||||
need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip
|
||||
need = {i: {p["box"] for p in _deps(lg, i)} for i in boxes}
|
||||
done: list[str] = []
|
||||
while len(done) < len(boxes):
|
||||
ready = [i for i in boxes if i not in done and need[i] <= set(done)]
|
||||
@@ -90,18 +121,24 @@ def _order(lg: dict) -> list[dict]:
|
||||
return [boxes[i] for i in done]
|
||||
|
||||
|
||||
# Model 기본 색(`M02_DF_Model.ts` BOX_COLOR) — 견본 색은 이것과 같아야 함
|
||||
BOX_COLOR = dict(
|
||||
re.findall(
|
||||
r'(\w+): "(#[0-9a-fA-F]{6})"',
|
||||
re.search(
|
||||
r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8")
|
||||
).group(1),
|
||||
)
|
||||
)
|
||||
def _trace_back(lg: dict, box_id: str) -> set[tuple[str, str]]:
|
||||
"""그 상자 값이 온 길의 출처 알약(입력 상자) — 선 · 규칙 조건 · 재료 조건을 거꾸로 끝까지"""
|
||||
boxes = {b["id"]: b for b in lg["boxes"]}
|
||||
seen: set[tuple[str, str]] = set()
|
||||
todo = [box_id]
|
||||
while todo:
|
||||
cur = todo.pop()
|
||||
for p in _deps(lg, cur):
|
||||
if boxes[p["box"]]["kind"] == "input":
|
||||
seen.add((p["box"], p["name"]))
|
||||
todo.append(p["box"]) # 재료 조건처럼 입력 상자도 다른 알약에 기댐
|
||||
else:
|
||||
todo.append(p["box"])
|
||||
return seen
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["masonry", "rate", "value"])
|
||||
# ── 모양 ─────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize("name", NAMES)
|
||||
def test_견본_모양(name: str) -> None:
|
||||
lg = SAMPLES[name]
|
||||
assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == []
|
||||
@@ -111,59 +148,98 @@ def test_견본_모양(name: str) -> None:
|
||||
assert len(set(sheets)) == len(sheets)
|
||||
for b in lg["boxes"]:
|
||||
assert b["kind"] in ("input", "function", "output")
|
||||
assert (
|
||||
b["color"] == BOX_COLOR[b["kind"]]
|
||||
and b["unit"]
|
||||
and isinstance(b["x"], int)
|
||||
and isinstance(b["y"], int)
|
||||
)
|
||||
assert b["color"] == BOX_COLOR[b["kind"]] and b["unit"]
|
||||
assert isinstance(b["x"], int) and isinstance(b["y"], int)
|
||||
if b["kind"] == "function":
|
||||
fn = b["func"]
|
||||
cells = fn["sheet"]["칸"]
|
||||
assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸
|
||||
assert "식" in fn["sheet"]["칸"][fn["output"]["cell"]] # 출력 하나 = 수식 칸
|
||||
assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"])
|
||||
if b["kind"] == "output":
|
||||
assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값")
|
||||
units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []}
|
||||
if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙)
|
||||
if len(units) == 1:
|
||||
assert b["unit"] == next(iter(units)), b["id"]
|
||||
for link in lg["links"]:
|
||||
a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]]
|
||||
assert link["from"]["name"] in _ports_out(a), link
|
||||
assert link["to"]["name"] in _ports_in(z), link
|
||||
# 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오
|
||||
assert a["kind"] != "output" and a["x"] < z["x"]
|
||||
for b in lg["boxes"]:
|
||||
for port in _ports_in(b):
|
||||
got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port]
|
||||
assert len(got) == 1, (b["id"], port)
|
||||
for link in lg["links"]:
|
||||
assert boxes[link["from"]["box"]]["kind"] != "output"
|
||||
assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"]
|
||||
# 규칙 · 재료 조건이 가리키는 알약은 있는 자리
|
||||
for b in lg["boxes"]:
|
||||
for p in _deps(lg, b["id"]):
|
||||
assert p["name"] in _ports_out(boxes[p["box"]]), p
|
||||
_order(lg)
|
||||
|
||||
|
||||
def test_견본_흐름_규칙_방식() -> None:
|
||||
"""견본마다 보여 주려는 흐름 — 조건 통과 선 둘 · 재료 조건 찾기 · 다른 로직 입력 · 출처 사슬"""
|
||||
g = SAMPLES["ground"]
|
||||
gates = [x for x in g["links"] if (x.get("rule") or {}).get("kind") == "gate"]
|
||||
assert len(gates) == 2 and {x["from"]["name"] for x in gates} == {"굴착량"}
|
||||
assert {x["rule"]["op"] for x in gates} == {"=", "≠"} and len(
|
||||
{x["rule"]["value"] for x in gates}
|
||||
) == 1
|
||||
assert ("in-ground", "지반") in _trace_back(g, "out-exp") # 조건 알약도 출처 사슬에
|
||||
|
||||
h = SAMPLES["hume"]
|
||||
pipe = next(b for b in h["boxes"] if b["id"] == "in-pipe")["input"]["elements"][0]
|
||||
assert pipe["mode"] == "rule" and pipe["slot"] == pipe["name"] and pipe["rule"]["op"] == "포함"
|
||||
assert pipe["scope"]["상세구분"] == "흄관" and pipe["default"] == pipe["ref"]
|
||||
assert _trace_back(h, "out-mat") == {
|
||||
("in-size", "연장"),
|
||||
("in-pipe", "흄관"),
|
||||
("in-size", "관경"),
|
||||
}
|
||||
|
||||
lk = SAMPLES["linked"]
|
||||
logic = next(b for b in lk["boxes"] if b["id"] == "in-logic")["input"]["logic"]
|
||||
called = SAMPLES[logic["key"]]
|
||||
assert [p["name"] for p in logic["outputs"]] == [
|
||||
b["name"] for b in called["boxes"] if b["kind"] == "output"
|
||||
]
|
||||
assert [p["name"] for p in logic["designs"]] == [
|
||||
v["name"] for b in called["boxes"] if b["kind"] == "input" for v in b["input"]["variables"]
|
||||
]
|
||||
assert ("in-logic", "시간당 작업량") in _trace_back(lk, "out-exp")
|
||||
|
||||
m = SAMPLES["masonry"]
|
||||
assert _trace_back(m, "out-pay") >= {
|
||||
("in-var", "높이"),
|
||||
("in-poom", "표"),
|
||||
("in-rate", "표"),
|
||||
("in-labor", "석공"),
|
||||
}
|
||||
|
||||
|
||||
def test_견본_목록_열기() -> None:
|
||||
got = _node(
|
||||
"out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });"
|
||||
"out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });"
|
||||
)
|
||||
assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"]
|
||||
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"]["boxes"] == SAMPLES["masonry"]["boxes"]
|
||||
assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"]
|
||||
assert got["none"] is None
|
||||
|
||||
|
||||
# ── 값 ───────────────────────────────────────────────────────────────────
|
||||
# ── 값(내부 확인) ─────────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def master():
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
cm = store.cm
|
||||
files = cm.load(folder=store.FOLDER)
|
||||
return cm, files
|
||||
return cm, cm.load(folder=store.FOLDER)
|
||||
|
||||
|
||||
def _plain(v):
|
||||
return float(v) if isinstance(v, Decimal) else v
|
||||
|
||||
|
||||
def _table_rows(table: dict) -> list[list]:
|
||||
"""품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)"""
|
||||
"""품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙"""
|
||||
conds, lines = table.get("조건") or {}, table.get("줄") or []
|
||||
names = [*conds, *(table.get("값칸") or {})]
|
||||
split = {
|
||||
@@ -175,37 +251,69 @@ def _table_rows(table: dict) -> list[list]:
|
||||
line: list = []
|
||||
for n in names:
|
||||
v = r.get(n)
|
||||
if n in split:
|
||||
line += list(v) if isinstance(v, list) else [v, v]
|
||||
else:
|
||||
line.append(v)
|
||||
line += (list(v) if isinstance(v, list) else [v, v]) if n in split else [v]
|
||||
rows.append(line)
|
||||
return [head, *rows]
|
||||
|
||||
|
||||
def _plain(v):
|
||||
return float(v) if isinstance(v, Decimal) else v
|
||||
def _cut(value, rnd):
|
||||
if value is None or not rnd:
|
||||
return value
|
||||
k = 10 ** rnd["places"]
|
||||
way = {"반올림": lambda x: math.floor(x + 0.5), "올림": math.ceil, "버림": math.floor}[
|
||||
rnd["method"]
|
||||
]
|
||||
return way(round(value * k, 9)) / k
|
||||
|
||||
|
||||
def _run(lg: dict, master) -> dict:
|
||||
"""상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값"""
|
||||
def _gate(rule, value, ports):
|
||||
have = ports[rule["from"]["box"]][rule["from"]["name"]]
|
||||
want = rule["value"]
|
||||
ok = {"=": have == want, "≠": have != want, "≥": have >= want, "≤": have <= want, ">": have > want, "<": have < want} # fmt: skip
|
||||
return value if ok[rule["op"]] else None
|
||||
|
||||
|
||||
def _run(lg: dict, master, examples: dict | None = None) -> dict:
|
||||
"""상자 차례대로 — 입력(예시값 · 노임 · 표 · 재료 · 다른 로직) → 선 규칙 → 함수 시트(엔진) → 끝수 → 출력"""
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
|
||||
cm, files = master
|
||||
m = cm.mf.Master(files)
|
||||
tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip
|
||||
items = next(d for d in files.values() if d.get("그룹") == "재료" and any("흄관" in str(r.get("이름")) for r in d["줄"]))["줄"] # fmt: skip
|
||||
ports: dict[str, dict] = {}
|
||||
for b in _order(lg):
|
||||
into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip
|
||||
into = {}
|
||||
for x in lg["links"]:
|
||||
if x["to"]["box"] == b["id"]:
|
||||
value, rule = ports[x["from"]["box"]][x["from"]["name"]], x.get("rule") or {}
|
||||
if rule.get("kind") == "gate":
|
||||
value = _gate(rule, value, ports)
|
||||
elif rule.get("kind") == "round":
|
||||
value = _cut(value, rule)
|
||||
into[x["to"]["name"]] = value
|
||||
if b["kind"] == "input":
|
||||
inp = b["input"]
|
||||
got = {v["name"]: v["example"] for v in inp.get("variables", [])}
|
||||
got |= {
|
||||
e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0])
|
||||
for e in inp.get("elements", [])
|
||||
got = {
|
||||
v["name"]: (examples or {}).get(v["name"], v["example"])
|
||||
for v in inp.get("variables", [])
|
||||
}
|
||||
for e in inp.get("elements", []):
|
||||
key = e["ref"]
|
||||
if (
|
||||
e.get("mode") == "rule"
|
||||
): # 범위 안 품목 중 규격 / 이름에 조건 알약 값이 든 것 하나
|
||||
want = str(ports[e["rule"]["from"]["box"]][e["rule"]["from"]["name"]])
|
||||
sc = e["scope"]
|
||||
hits = [r for r in items if r.get("구분") == sc["구분"] and r.get("상세구분") == sc["상세구분"]
|
||||
and sc["찾는말"] in r.get("이름", "") and want in str(r.get(e["rule"]["field"], ""))] # fmt: skip
|
||||
assert len(hits) == 1, (want, [h["키"] for h in hits])
|
||||
key = hits[0]["키"]
|
||||
got[e["name"]] = _plain(cm.mf.element(m, key, {})[0])
|
||||
if inp.get("table"):
|
||||
got["표"] = _table_rows(tables[inp["table"]["key"]])
|
||||
if inp["mode"] == "logic":
|
||||
got = _run(SAMPLES[inp["logic"]["key"]], master) # 설계 값은 그 로직의 예시값
|
||||
ports[b["id"]] = got
|
||||
elif b["kind"] == "function":
|
||||
fn = b["func"]
|
||||
@@ -213,63 +321,48 @@ def _run(lg: dict, master) -> dict:
|
||||
for i in fn["inputs"]:
|
||||
value = into[i["port"]]
|
||||
col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:])
|
||||
block = value if isinstance(value, list) else [[value]]
|
||||
for r, line in enumerate(block):
|
||||
for r, line in enumerate(value if isinstance(value, list) else [[value]]):
|
||||
for c, x in enumerate(line):
|
||||
if x is not None:
|
||||
sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)}
|
||||
book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]}
|
||||
out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]]
|
||||
assert "수" in out, out
|
||||
ports[b["id"]] = {fn["output"]["name"]: float(out["수"])}
|
||||
book = {"종류": "통합문서", "판": 1, "열": "df", "서식": [{}], "시트": [sheet]}
|
||||
got = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]]
|
||||
assert "수" in got, (b["id"], got)
|
||||
ports[b["id"]] = {
|
||||
fn["output"]["name"]: _cut(float(got["수"]), fn["output"].get("round"))
|
||||
}
|
||||
else:
|
||||
ports[b["id"]] = {"값": into["값"]}
|
||||
return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"}
|
||||
ports[b["id"]] = {"값": _cut(into["값"], b["output"].get("round"))}
|
||||
return {b["name"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"}
|
||||
|
||||
|
||||
def test_견본_표_그룹이_마스터와_같음(master) -> None:
|
||||
"""표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)"""
|
||||
cm, files = master
|
||||
group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip
|
||||
for lg in SAMPLES.values():
|
||||
for b in lg["boxes"]:
|
||||
table = b.get("input", {}).get("table")
|
||||
if table:
|
||||
assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"])
|
||||
|
||||
|
||||
def test_견본1_원가_calc_와_같음(master) -> None:
|
||||
def test_값_메쌓기_value_는_calc_와_같음(master) -> None:
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
cm, files = master
|
||||
row = next(
|
||||
r
|
||||
for d in files.values()
|
||||
if d.get("그룹") == "로직"
|
||||
for r in d["줄"]
|
||||
if r["키"] == "GF000219"
|
||||
)
|
||||
_, files = master
|
||||
rows = {r["키"]: r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]}
|
||||
raw = lambda k: json.loads(json.dumps(rows[k], default=_plain)) # noqa: E731
|
||||
given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]}
|
||||
want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {})
|
||||
assert want["ok"], want
|
||||
got = _run(SAMPLES["masonry"], master)["out-pay"]
|
||||
print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}")
|
||||
assert abs(got - float(want["sums"]["계"])) < 1
|
||||
|
||||
|
||||
def test_견본3_값_calc_와_같음_견본2_비율(master) -> None:
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
cm, files = master
|
||||
row = next(
|
||||
r
|
||||
for d in files.values()
|
||||
if d.get("그룹") == "로직"
|
||||
for r in d["줄"]
|
||||
if r["키"] == "GF000133"
|
||||
)
|
||||
want = lb.calc(raw("GF000219"), {**given, "초과증가율": 80}, {})
|
||||
assert abs(_run(SAMPLES["masonry"], master)["노무비"] - float(want["sums"]["계"])) < 1
|
||||
given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]}
|
||||
given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」
|
||||
want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {})
|
||||
assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"])
|
||||
assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100
|
||||
given["qt"] = given.pop("q")
|
||||
assert _run(SAMPLES["value"], master)["시간당 작업량"] == float(
|
||||
lb.calc(raw("GF000133"), given, {})["result"]
|
||||
)
|
||||
|
||||
|
||||
def test_값_지반_흄관_다른로직(master) -> None:
|
||||
# 지반 = 토사 → 굴삭기 길만 · 암 → 브레이커 길만(조건 통과 안 된 선은 빈 칸 = 0)
|
||||
assert _run(SAMPLES["ground"], master)["경비"] == 100 / 30 * 60000 / 100
|
||||
assert _run(SAMPLES["ground"], master, {"지반": "암"})["경비"] == 100 / 8 * 80000 / 100
|
||||
# 관경 600 → Φ600mm 품목 · 4 본 × 단가 ÷ 10 m · 0.1 원 미만 버림
|
||||
cm, files = master
|
||||
price = float(cm.mf.element(cm.mf.Master(files), "MT014991", {})[0])
|
||||
assert _run(SAMPLES["hume"], master)["재료비"] == _cut(
|
||||
4 * price / 10, {"places": 1, "method": "버림"}
|
||||
)
|
||||
# 다른 로직(작업량 15.552) → 운반 시간 120 ÷ 15.552 = 7.72(소수 둘째 반올림) → 7.72 × 50,000 ÷ 120
|
||||
assert _run(SAMPLES["linked"], master)["경비"] == _cut(
|
||||
7.72 * 50000 / 120, {"places": 1, "method": "버림"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user