Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
@@ -75,6 +75,50 @@ interface Opened extends Draft {
|
||||
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
|
||||
|
||||
/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */
|
||||
function trialMenu(items: HTMLElement[]): HTMLElement {
|
||||
const list = el("div", { attrs: { role: "menu" }, children: items });
|
||||
Object.assign(list.style, {
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
left: "0",
|
||||
zIndex: "20",
|
||||
display: "none",
|
||||
flexDirection: "column",
|
||||
gap: "var(--spacing-4)",
|
||||
padding: "var(--spacing-8)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "var(--color-surface-raised)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
const shut = (): void => {
|
||||
list.style.display = "none";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", outside, true);
|
||||
};
|
||||
const outside = (ev: Event): void => {
|
||||
if (!wrap.contains(ev.target as Node)) shut();
|
||||
};
|
||||
const button = createButton({
|
||||
label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0],
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
if (list.style.display !== "none") return shut();
|
||||
list.style.display = "flex";
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", outside, true);
|
||||
},
|
||||
});
|
||||
button.setAttribute("aria-haspopup", "menu");
|
||||
list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶
|
||||
const wrap = el("div", { children: [button, list] });
|
||||
Object.assign(wrap.style, { position: "relative", display: "inline-flex" });
|
||||
wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut());
|
||||
return wrap;
|
||||
}
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
function loadDrafts(): Record<string, Draft> {
|
||||
@@ -406,14 +450,16 @@ export async function mountM01LogicLab(
|
||||
el("h2", { text: tx("Title") }),
|
||||
pending,
|
||||
createButton({ label: tn("Open"), variant: "filled", onClick: startNew }),
|
||||
// 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createEasyCalcButton(),
|
||||
// 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createLogicBuilderButton(),
|
||||
// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createSheetLogicButton(),
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createCostFlowButton(),
|
||||
trialMenu([
|
||||
// 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createEasyCalcButton(),
|
||||
// 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createLogicBuilderButton(),
|
||||
// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createSheetLogicButton(),
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createCostFlowButton(),
|
||||
]),
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
discardButton,
|
||||
saveButton,
|
||||
|
||||
@@ -256,3 +256,21 @@
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
/* 함수 상자 흐린 「+」 받는 자리 */
|
||||
.m02-cfdg__pill.is-plus {
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.m02-cfdg__pill.is-plus:hover,
|
||||
.m02-cfdg.is-linking .m02-cfdg__pill.is-plus.is-target {
|
||||
opacity: 1;
|
||||
border-color: var(--cfdg-in);
|
||||
color: var(--cfdg-in);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ const TEXT = {
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
PlusIn: [
|
||||
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
|
||||
"Drop an output pill here to add an input",
|
||||
],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
Hint: [
|
||||
"상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대",
|
||||
@@ -89,29 +93,9 @@ export interface DiagramOpts {
|
||||
|
||||
/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
/** 알약 — Model 이 아직이면 상자 칸에서 바로 뽑음 */
|
||||
/** 알약 — Model 의 받는 · 내보내는 알약 */
|
||||
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
|
||||
try {
|
||||
return { ins: portsIn(box), outs: portsOut(box) };
|
||||
} catch {
|
||||
if (box.kind === "input") {
|
||||
const inp = box.input;
|
||||
const outs = [
|
||||
...(inp?.elements ?? []).map((e) => ({ name: e.name, unit: e.unit })),
|
||||
...(inp?.table ? [{ name: "표", unit: "-" }] : []),
|
||||
...(inp?.variables ?? []).map((v) => ({ name: v.name, unit: v.unit })),
|
||||
];
|
||||
return { ins: [], outs };
|
||||
}
|
||||
if (box.kind === "function") {
|
||||
const f = box.func;
|
||||
return {
|
||||
ins: (f?.inputs ?? []).map((p) => ({ name: p.port, unit: "-" })),
|
||||
outs: f ? [{ name: f.output.name, unit: f.output.unit }] : [],
|
||||
};
|
||||
}
|
||||
return { ins: [{ name: "값", unit: box.unit }], outs: [] };
|
||||
}
|
||||
return { ins: portsIn(box), outs: portsOut(box) };
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
|
||||
@@ -124,6 +108,9 @@ export function resolveDrop(
|
||||
if (target.box === from.box) return { why: tt("SameBox") };
|
||||
if (target.dir === "out") return { why: tt("NotIn") };
|
||||
const box = lg.boxes.find((b) => b.id === target.box);
|
||||
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
|
||||
if (box?.kind === "function" && target.name === null)
|
||||
return { to: { box: box.id, name: from.name } };
|
||||
const ins = box ? pillsOf(box).ins : [];
|
||||
if (!box || !ins.length) return { why: tt("NoIn") };
|
||||
const name =
|
||||
@@ -163,7 +150,10 @@ export function arrange(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 */
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
|
||||
export const FIT_MIN = 0.9;
|
||||
|
||||
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
|
||||
export function fitView(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
view: { w: number; h: number },
|
||||
@@ -174,12 +164,11 @@ export function fitView(
|
||||
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
|
||||
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
|
||||
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
|
||||
const zoom = clampZoom(Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5));
|
||||
return {
|
||||
zoom,
|
||||
panX: (view.w - (x1 - x0) * zoom) / 2 - x0 * zoom,
|
||||
panY: (view.h - (y1 - y0) * zoom) / 2 - y0 * zoom,
|
||||
};
|
||||
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
|
||||
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
|
||||
const place = (size: number, from: number, to: number): number =>
|
||||
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
|
||||
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
|
||||
}
|
||||
|
||||
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
|
||||
@@ -201,10 +190,15 @@ interface HostState {
|
||||
const states = new WeakMap<HTMLElement, HostState>();
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
const showValue = (v: unknown): string => {
|
||||
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
|
||||
export const showValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
if (typeof v === "number")
|
||||
return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
|
||||
if (Array.isArray(v)) {
|
||||
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
|
||||
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
|
||||
}
|
||||
return String(v).slice(0, 12);
|
||||
};
|
||||
|
||||
@@ -300,6 +294,28 @@ export function renderDiagram(
|
||||
return btn;
|
||||
};
|
||||
|
||||
/** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */
|
||||
const plusSlot = (b: CfBox): HTMLElement => {
|
||||
const slot = el("button", {
|
||||
className: `m02-cfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`,
|
||||
text: "+",
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-box": b.id,
|
||||
"data-dir": "in",
|
||||
"data-plus": "1",
|
||||
title: tt("PlusIn"),
|
||||
},
|
||||
});
|
||||
slot.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" });
|
||||
st.note = tt("PlusIn");
|
||||
redraw();
|
||||
});
|
||||
return slot;
|
||||
};
|
||||
|
||||
const boxEl = (b: CfBox): HTMLElement => {
|
||||
const { ins, outs } = pillsOf(b);
|
||||
const head = el("div", {
|
||||
@@ -323,7 +339,13 @@ export function renderDiagram(
|
||||
el("div", {
|
||||
className: "m02-cfdg__pills",
|
||||
children: [
|
||||
el("div", { className: "m02-cfdg__ins", children: ins.map((p) => pillEl(b, p, "in")) }),
|
||||
el("div", {
|
||||
className: "m02-cfdg__ins",
|
||||
children: [
|
||||
...ins.map((p) => pillEl(b, p, "in")),
|
||||
...(b.kind === "function" ? [plusSlot(b)] : []),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m02-cfdg__outs",
|
||||
children: outs.map((p) => pillEl(b, p, "out")),
|
||||
|
||||
@@ -115,10 +115,6 @@
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-cf__diagram .m02-cf__part-body {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.m02-cf__lower {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
|
||||
@@ -189,3 +185,36 @@
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-cf__note {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m02-cf__note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-cf__color {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-cf__color[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-cf__color-input {
|
||||
width: 44px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Entry.ts
|
||||
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀
|
||||
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
|
||||
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게.
|
||||
* 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음.
|
||||
* 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary
|
||||
* → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S.
|
||||
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -17,21 +18,49 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CfListItem } from "./M02_CF_Api";
|
||||
import type { CfLogic } from "./M02_CF_Model";
|
||||
import type { CfListItem, CfQuery } from "./M02_CF_Api";
|
||||
import type { CfEvalResult, CfValues } from "./M02_CF_Eval";
|
||||
import type { CfKind, CfLogic, CfPort } from "./M02_CF_Model";
|
||||
import "./M02_CF_Entry.css";
|
||||
|
||||
/** CostFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
|
||||
const loadMods = () =>
|
||||
Promise.all([
|
||||
import("./M02_CF_Model"),
|
||||
import("./M02_CF_Eval"),
|
||||
import("./M02_CF_Api"),
|
||||
import("./M02_CF_Diagram"),
|
||||
import("./M02_CF_FuncPanel"),
|
||||
import("./M02_CF_OutputPanel"),
|
||||
import("./M02_CF_Summary"),
|
||||
import("./M02_CF_Sample"),
|
||||
import("./M02_CF_InputPanel"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
diagram,
|
||||
func,
|
||||
output,
|
||||
summary,
|
||||
sample,
|
||||
input,
|
||||
}));
|
||||
type Mods = Awaited<ReturnType<typeof loadMods>>;
|
||||
|
||||
const TEXT = {
|
||||
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
|
||||
Title: ["단가산출 다이어그램", "Cost diagram"],
|
||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
|
||||
Start_New: ["새로 만들기", "New"],
|
||||
Start_Mine: ["내 로직", "My logics"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Samples: ["견본", "Samples"],
|
||||
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
|
||||
Name: ["로직 이름", "Logic name"],
|
||||
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
@@ -41,11 +70,24 @@ const TEXT = {
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Diagram: ["다이어그램", "Diagram"],
|
||||
Box: ["고른 상자", "Chosen box"],
|
||||
Panel_Input: ["입력", "Input"],
|
||||
Panel_Function: ["함수", "Function"],
|
||||
Panel_Output: ["출력", "Output"],
|
||||
PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"],
|
||||
Summary: ["원가", "Cost"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
Color: ["상자 색", "Box colour"],
|
||||
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
|
||||
SaveAnyway: [
|
||||
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
|
||||
"There are issues — {v} · Save anyway?",
|
||||
],
|
||||
Saving: ["저장 중", "Saving"],
|
||||
Saved: ["저장했음", "Saved"],
|
||||
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
|
||||
Differs: [
|
||||
"저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름",
|
||||
"Recalculated on save: {s} — differs from the screen value {c}",
|
||||
],
|
||||
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||
Deleted: ["지웠음", "Deleted"],
|
||||
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
@@ -55,17 +97,14 @@ const TEXT = {
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const te = (key: TextKey): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
|
||||
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text });
|
||||
|
||||
/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */
|
||||
const blankLogic = (name: string, unit: string): Promise<CfLogic | null> =>
|
||||
import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null);
|
||||
const loadList = (): Promise<CfListItem[]> =>
|
||||
import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []);
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
@@ -83,6 +122,46 @@ async function unitChoices(): Promise<string[]> {
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
||||
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
|
||||
return lg.boxes.flatMap((b) => {
|
||||
const inp = b.input;
|
||||
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
|
||||
const group = inp.group;
|
||||
const els = inp.elements
|
||||
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
|
||||
.map((e) => {
|
||||
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
|
||||
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
|
||||
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
|
||||
if (field) q.field = field;
|
||||
return { q, box: b.id, port: e.name };
|
||||
});
|
||||
const table = inp.table
|
||||
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
|
||||
: [];
|
||||
return [...els, ...table];
|
||||
});
|
||||
}
|
||||
|
||||
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
|
||||
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
|
||||
const asks = queriesOf(lg);
|
||||
if (!asks.length) return { values: {}, errors: [] };
|
||||
const answers = await M.api.cfValues(
|
||||
asks.map((a) => a.q),
|
||||
lg.userElements as never,
|
||||
);
|
||||
const values: CfValues = {};
|
||||
const errors: string[] = [];
|
||||
for (const a of asks) {
|
||||
const got = answers.find((x) => x.id === a.q.id);
|
||||
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
|
||||
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
|
||||
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
|
||||
export function createCostFlowButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow });
|
||||
@@ -90,8 +169,9 @@ export function createCostFlowButton(): HTMLButtonElement {
|
||||
|
||||
function openCostFlow(): void {
|
||||
/** 고치는 화면이 열려 있을 때만 */
|
||||
let paper: { dirty(): boolean } | null = null;
|
||||
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
|
||||
let units: string[] | null = null;
|
||||
const mods = loadMods();
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
@@ -103,7 +183,7 @@ function openCostFlow(): void {
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
if (await mayLeave()) paper ? void showStart() : shut();
|
||||
})(),
|
||||
});
|
||||
const page = el("div", {
|
||||
@@ -128,42 +208,52 @@ function openCostFlow(): void {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
page.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape" || ev.defaultPrevented) return;
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
})();
|
||||
if (ev.defaultPrevented) return;
|
||||
if (ev.key === "Escape") {
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (key === "z" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
} else if (key === "s") {
|
||||
ev.preventDefault();
|
||||
void paper.save();
|
||||
}
|
||||
});
|
||||
|
||||
const run = (job: (M: Mods) => Promise<void>): void => {
|
||||
body.replaceChildren(muted(te("Loading")));
|
||||
void mods.then(job).catch((e: unknown) => {
|
||||
showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error");
|
||||
void showStart();
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: CfListItem): HTMLElement => {
|
||||
const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m02-cf__item",
|
||||
attrs: { type: "button", title: item.saved },
|
||||
attrs: { type: "button", title },
|
||||
children: [
|
||||
el("span", { className: "m02-cf__item-name", text: item.name }),
|
||||
el("span", { className: "m02-cf__item-unit", text: item.resultUnit }),
|
||||
el("span", { className: "m02-cf__item-name", text: name }),
|
||||
el("span", { className: "m02-cf__item-unit", text: unit }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => showToast(te("NotYet"), "info"));
|
||||
row.addEventListener("click", onClick);
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = (): void => {
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
strip.replaceChildren();
|
||||
const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
|
||||
const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
|
||||
void loadList().then((all) => {
|
||||
const mine = all.filter((x) => !x.sample);
|
||||
const samples = all.filter((x) => x.sample);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
sampleHost.replaceChildren(
|
||||
...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
});
|
||||
const sampleHost = el("div", { className: "m02-cf__list" });
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-cf__start",
|
||||
@@ -171,7 +261,9 @@ function openCostFlow(): void {
|
||||
el("section", {
|
||||
className: "m02-cf__start-col",
|
||||
children: [
|
||||
bigButton(te("Start_New"), () => void openNew()),
|
||||
bigButton(te("Start_New"), () =>
|
||||
run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)),
|
||||
),
|
||||
el("h3", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
@@ -183,41 +275,254 @@ function openCostFlow(): void {
|
||||
],
|
||||
}),
|
||||
);
|
||||
const M = await mods;
|
||||
sampleHost.replaceChildren(
|
||||
...M.sample.sampleList().map((s) =>
|
||||
listRow(
|
||||
s.name,
|
||||
s.resultUnit,
|
||||
() =>
|
||||
run(async (M2) => {
|
||||
const lg = M2.sample.sampleLoad(s.id);
|
||||
if (lg) await showPaper(M2, lg, false);
|
||||
}),
|
||||
s.note,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map((x) =>
|
||||
listRow(
|
||||
x.name,
|
||||
x.resultUnit,
|
||||
() => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)),
|
||||
x.saved,
|
||||
),
|
||||
)
|
||||
: [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
} catch (e) {
|
||||
mineHost.replaceChildren(muted(M.api.cfPlain(e)));
|
||||
}
|
||||
};
|
||||
const units_ = async (): Promise<string[]> => (units ??= await unitChoices());
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const openNew = async (): Promise<void> => {
|
||||
units ??= await unitChoices();
|
||||
const lg = await blankLogic("", units[0]);
|
||||
showPaper(lg, units);
|
||||
};
|
||||
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
|
||||
const choices = await units_();
|
||||
let lg = first;
|
||||
let savedLg: CfLogic | null = saved ? first : null;
|
||||
let stack: CfLogic[] = [];
|
||||
let selected: string | null = null;
|
||||
let values: CfValues = {};
|
||||
let valueSig = "";
|
||||
let notes: string[] = [];
|
||||
let result: CfEvalResult;
|
||||
let busy = false;
|
||||
|
||||
const showPaper = (lg: CfLogic | null, choices: string[]): void => {
|
||||
let dirty = false;
|
||||
paper = { dirty: () => dirty };
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
const touched = (): void => {
|
||||
dirty = true;
|
||||
unsaved.hidden = false;
|
||||
const note = el("p", { className: "m02-cf__note", attrs: { role: "status" } });
|
||||
const diagramHost = el("div");
|
||||
const pickHint = muted(te("PickBox"));
|
||||
// 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나
|
||||
const colorBox = el("input", {
|
||||
className: "m02-cf__color-input",
|
||||
attrs: { type: "color", "aria-label": te("Color") },
|
||||
});
|
||||
colorBox.addEventListener("change", () => {
|
||||
if (selected) edit(M.model.setColor(lg, selected, colorBox.value));
|
||||
});
|
||||
const colorRow = el("label", {
|
||||
className: "m02-cf__color",
|
||||
children: [el("span", { text: te("Color") }), colorBox],
|
||||
});
|
||||
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김)
|
||||
const funcHost = el("div");
|
||||
const outHost = el("div");
|
||||
const inHost = el("div");
|
||||
const summaryHost = el("div");
|
||||
|
||||
const evalNow = (): CfEvalResult => {
|
||||
try {
|
||||
return M.ev.evalLogic(lg, values);
|
||||
} catch (e) {
|
||||
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
|
||||
}
|
||||
};
|
||||
|
||||
const edit = (next: CfLogic): void => {
|
||||
if (next === lg) return;
|
||||
stack = [...stack, lg];
|
||||
lg = next;
|
||||
redraw();
|
||||
};
|
||||
/** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */
|
||||
const editFromDiagram = (next: CfLogic): void => {
|
||||
edit(next);
|
||||
setTimeout(redraw);
|
||||
};
|
||||
|
||||
/** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
|
||||
const refreshValues = (): void => {
|
||||
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
|
||||
if (sig === valueSig) return;
|
||||
valueSig = sig;
|
||||
void fetchValues(M, lg).then(
|
||||
(got) => {
|
||||
if (sig !== valueSig) return;
|
||||
values = got.values;
|
||||
notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : [];
|
||||
redraw();
|
||||
},
|
||||
(e: unknown) => {
|
||||
notes = [te("Values", { v: M.api.cfPlain(e) })];
|
||||
redraw();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: lg?.name ?? "",
|
||||
onInput: touched,
|
||||
value: lg.name,
|
||||
onInput: (v) => edit({ ...lg, name: v }),
|
||||
});
|
||||
name.root.classList.add("m02-cf__name");
|
||||
const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices];
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: lg?.resultUnit ?? choices[0],
|
||||
onChange: touched,
|
||||
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: lg.resultUnit,
|
||||
onChange: (v) => edit({ ...lg, resultUnit: v }),
|
||||
});
|
||||
|
||||
const diagramOn = {
|
||||
select: (id: string | null): void => {
|
||||
selected = id;
|
||||
drawPanel();
|
||||
},
|
||||
move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)),
|
||||
link: (from: CfPort, to: CfPort): string | null => {
|
||||
const got = M.model.link(lg, from, to);
|
||||
if (got.why) return got.why;
|
||||
editFromDiagram(got.lg);
|
||||
return null;
|
||||
},
|
||||
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;
|
||||
editFromDiagram(got.lg);
|
||||
},
|
||||
remove: (id: string): void => {
|
||||
if (selected === id) selected = null;
|
||||
editFromDiagram(M.model.removeBox(lg, id));
|
||||
},
|
||||
};
|
||||
|
||||
const drawPanel = (): void => {
|
||||
const box = lg.boxes.find((b) => b.id === selected);
|
||||
pickHint.hidden = !!box;
|
||||
colorRow.hidden = !box;
|
||||
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
|
||||
funcHost.hidden = box?.kind !== "function";
|
||||
outHost.hidden = box?.kind !== "output";
|
||||
inHost.hidden = box?.kind !== "input";
|
||||
if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit });
|
||||
if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit });
|
||||
if (box?.kind === "output")
|
||||
M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
|
||||
};
|
||||
|
||||
const redraw = (): void => {
|
||||
result = evalNow();
|
||||
refreshValues();
|
||||
unsaved.hidden = lg === savedLg;
|
||||
undo.disabled = !stack.length;
|
||||
deleteButton.hidden = !lg.key;
|
||||
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
|
||||
drawPanel();
|
||||
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
|
||||
// ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠)
|
||||
(M.summary.renderSummary as (...args: unknown[]) => void)(
|
||||
summaryHost,
|
||||
lg,
|
||||
result,
|
||||
{ change: edit },
|
||||
{ values },
|
||||
);
|
||||
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
|
||||
};
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (busy) return;
|
||||
if (!lg.name.trim()) {
|
||||
note.textContent = te("NoName");
|
||||
return void name.input.focus();
|
||||
}
|
||||
let problems: string[] = [];
|
||||
try {
|
||||
problems = M.ev.checkLogic(lg, values);
|
||||
} catch {
|
||||
problems = [];
|
||||
}
|
||||
const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
|
||||
if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return;
|
||||
busy = true;
|
||||
note.textContent = te("Saving");
|
||||
const screen = result.cost.계;
|
||||
try {
|
||||
const got = await M.api.cfSave(lg);
|
||||
lg = got.logic;
|
||||
savedLg = lg;
|
||||
stack = [];
|
||||
redraw();
|
||||
const server = got.values.cost.계;
|
||||
if (Math.abs(server - screen) > 0.5)
|
||||
notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
|
||||
note.textContent = [...notes, ...result.errors].join("\n");
|
||||
showToast(te("Saved"), "success");
|
||||
} catch (e) {
|
||||
note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) });
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = stack[stack.length - 1];
|
||||
if (!back) return;
|
||||
stack = stack.slice(0, -1);
|
||||
lg = back;
|
||||
name.input.value = lg.name;
|
||||
unit.select.value = lg.resultUnit;
|
||||
redraw();
|
||||
};
|
||||
|
||||
const doDelete = async (): Promise<void> => {
|
||||
if (!lg.key || busy) return;
|
||||
if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return;
|
||||
try {
|
||||
await M.api.cfDelete(lg.key);
|
||||
savedLg = lg;
|
||||
showToast(te("Deleted"), "success");
|
||||
void showStart();
|
||||
} catch (e) {
|
||||
note.textContent = M.api.cfPlain(e);
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
|
||||
const deleteButton = createButton({
|
||||
label: te("Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void doDelete(),
|
||||
});
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
|
||||
undo.disabled = true;
|
||||
strip.replaceChildren(
|
||||
name.root,
|
||||
unit.root,
|
||||
@@ -226,39 +531,33 @@ function openCostFlow(): void {
|
||||
className: "m02-cf__actions",
|
||||
children: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
createButton({ label: te("Delete"), variant: "danger", onClick: notYet }),
|
||||
createButton({ label: te("Save"), onClick: () => void save() }),
|
||||
deleteButton,
|
||||
],
|
||||
}),
|
||||
);
|
||||
body.replaceChildren(
|
||||
note,
|
||||
el("div", {
|
||||
className: "m02-cf__layout",
|
||||
children: [
|
||||
part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]),
|
||||
part(te("Diagram"), "m02-cf__diagram", [diagramHost]),
|
||||
el("div", {
|
||||
className: "m02-cf__lower",
|
||||
children: [
|
||||
part(te("Box"), "m02-cf__box", [
|
||||
el("div", {
|
||||
className: "m02-cf__box-slots",
|
||||
children: [
|
||||
slot(te("Panel_Input")),
|
||||
slot(te("Panel_Function")),
|
||||
slot(te("Panel_Output")),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]),
|
||||
part(te("Box"), "m02-cf__box", [pickHint, colorRow, inHost, funcHost, outHost]),
|
||||
part(te("Summary"), "m02-cf__summary", [summaryHost]),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
name.input.focus();
|
||||
paper = { dirty: () => lg !== savedLg, save, undo: doUndo };
|
||||
redraw();
|
||||
if (!lg.name) name.input.focus();
|
||||
};
|
||||
|
||||
showStart();
|
||||
void showStart();
|
||||
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
|
||||
document.body.append(page);
|
||||
page.tabIndex = -1;
|
||||
@@ -273,14 +572,6 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl
|
||||
});
|
||||
}
|
||||
|
||||
/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */
|
||||
function slot(title: string): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m02-cf__slot",
|
||||
children: [el("h4", { text: title }), muted(te("NotYet"))],
|
||||
});
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m02-cf__big");
|
||||
|
||||
@@ -53,6 +53,10 @@ const TEXT = {
|
||||
],
|
||||
NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"],
|
||||
NoOutput: ["출력 상자가 없음", "There is no output box"],
|
||||
NoInCell: [
|
||||
"「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음",
|
||||
"Pill 「{pill}」 of 「{box}」 is not placed in a cell",
|
||||
],
|
||||
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
|
||||
} as const;
|
||||
|
||||
@@ -193,6 +197,8 @@ export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] {
|
||||
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);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
elementOf,
|
||||
machineFields,
|
||||
queryOf,
|
||||
reconcileUnit,
|
||||
removeElement,
|
||||
setKind,
|
||||
valueOf,
|
||||
@@ -143,6 +144,9 @@ export function renderInputPanel(
|
||||
host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") }));
|
||||
return;
|
||||
}
|
||||
// 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감
|
||||
const fixed = reconcileUnit(lg, boxId);
|
||||
if (fixed !== lg) return on.change(fixed);
|
||||
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
|
||||
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
|
||||
const body = el("div", { className: "m02-cfip__body" });
|
||||
|
||||
@@ -282,3 +282,13 @@ export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pi
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */
|
||||
export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
|
||||
const box = lg.boxes.find((b) => b.id === id);
|
||||
if (!box?.input) return lg;
|
||||
const want = unitOfInput(box.input);
|
||||
return box.unit === want
|
||||
? lg
|
||||
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
|
||||
}
|
||||
|
||||
@@ -188,24 +188,29 @@ export function addBox(
|
||||
);
|
||||
const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y };
|
||||
if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] };
|
||||
if (kind === "function")
|
||||
if (kind === "function") {
|
||||
// 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음
|
||||
const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : []));
|
||||
let n = 1;
|
||||
while (taken.includes(`${tl("Result")} ${n}`)) n++;
|
||||
const out = `${tl("Result")} ${n}`;
|
||||
box.func = {
|
||||
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } },
|
||||
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } },
|
||||
inputs: [],
|
||||
variables: [],
|
||||
output: { cell: "B1", name: tl("Result"), unit: "-" },
|
||||
output: { cell: "B1", name: out, unit: "-" },
|
||||
};
|
||||
}
|
||||
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
|
||||
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
|
||||
}
|
||||
|
||||
/** 상자 지우기 — 이어진 선도 지움 */
|
||||
export function removeBox(lg: CfLogic, id: string): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.filter((b) => b.id !== id),
|
||||
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id),
|
||||
};
|
||||
// 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리
|
||||
let next = lg;
|
||||
for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id);
|
||||
return { ...next, boxes: next.boxes.filter((b) => b.id !== id) };
|
||||
}
|
||||
|
||||
export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic {
|
||||
@@ -267,7 +272,9 @@ export function reaches(lg: CfLogic, from: string, to: string): boolean {
|
||||
return walk(from);
|
||||
}
|
||||
|
||||
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */
|
||||
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음
|
||||
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
|
||||
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
|
||||
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
|
||||
const a = boxOf(lg, from.box);
|
||||
const b = boxOf(lg, to.box);
|
||||
@@ -275,21 +282,48 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why:
|
||||
if (a.kind === "output") return { lg, why: tl("FromOutput") };
|
||||
if (b.kind === "input") return { lg, why: tl("ToInput") };
|
||||
if (a.id === b.id) return { lg, why: tl("Self") };
|
||||
if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name))
|
||||
return { lg, why: tl("NoPill") };
|
||||
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") };
|
||||
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
|
||||
if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
|
||||
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
|
||||
return { lg, why: tl("Taken") };
|
||||
let next = lg;
|
||||
let port = to.name;
|
||||
if (!has) {
|
||||
const used = portsIn(b).map((p) => p.name);
|
||||
const base = to.name.trim() || from.name;
|
||||
port = used.includes(base) ? uniqueSuffix(used, base) : base;
|
||||
const func = b.func!;
|
||||
next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } });
|
||||
}
|
||||
const id = nextId(
|
||||
"l",
|
||||
lg.links.map((l) => l.id),
|
||||
next.links.map((l) => l.id),
|
||||
);
|
||||
return {
|
||||
lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] },
|
||||
lg: {
|
||||
...next,
|
||||
links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }],
|
||||
},
|
||||
why: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
|
||||
function uniqueSuffix(used: string[], base: string): string {
|
||||
for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`;
|
||||
}
|
||||
|
||||
/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */
|
||||
export function unlink(lg: CfLogic, id: string): CfLogic {
|
||||
return { ...lg, links: lg.links.filter((l) => l.id !== id) };
|
||||
const gone = lg.links.find((l) => l.id === id);
|
||||
const next = { ...lg, links: lg.links.filter((l) => l.id !== id) };
|
||||
const box = gone && boxOf(next, gone.to.box);
|
||||
if (!gone || !box?.func) return next;
|
||||
const port = box.func.inputs.find((i) => i.port === gone.to.name);
|
||||
const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name);
|
||||
if (!port || port.cell.trim() || still) return next;
|
||||
const inputs = box.func.inputs.filter((i) => i !== port);
|
||||
return updateBox(next, box.id, { func: { ...box.func, inputs } });
|
||||
}
|
||||
|
||||
@@ -342,10 +342,70 @@ def _user(uid: str, local: list[dict]) -> dict:
|
||||
return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")}
|
||||
|
||||
|
||||
MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」
|
||||
HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝)
|
||||
_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
|
||||
|
||||
|
||||
def _machine_family() -> list[dict]:
|
||||
"""기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직."""
|
||||
files = store.loaded()[0]
|
||||
return [
|
||||
row
|
||||
for name, data in files.items()
|
||||
if data.get("그룹") == "로직" and "건설기계" in name
|
||||
for row in data.get("줄") or []
|
||||
if str(row.get("결과단위") or "").startswith("원")
|
||||
and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or [])
|
||||
]
|
||||
|
||||
|
||||
def _machine_rank(row: dict) -> tuple:
|
||||
"""짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키."""
|
||||
names = [i.get("이름") for i in row.get("입력") or []]
|
||||
codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계")
|
||||
attach = any(n not in _MACHINE_PLAIN for n in names)
|
||||
loss = "지역" not in names
|
||||
return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키")))
|
||||
|
||||
|
||||
def _machine_hourly(key: str, inputs: dict) -> dict:
|
||||
"""기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기."""
|
||||
row, why = _brief(key, "기계")
|
||||
if row is None:
|
||||
return {"ok": False, "reason": why}
|
||||
code = str(row.get("원문번호") or "")
|
||||
fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]]
|
||||
if not fit:
|
||||
return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"}
|
||||
logic = min(fit, key=_machine_rank)
|
||||
given = {}
|
||||
for spec in logic.get("입력") or []:
|
||||
name = spec.get("이름")
|
||||
if name != "기계":
|
||||
given[name] = inputs.get(name, (spec.get("고르기") or [None])[0])
|
||||
args = "".join(f", {n}='{v}'" for n, v in given.items())
|
||||
ref = f"로직({logic['키']}, 기계='{code}'{args})"
|
||||
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
|
||||
got = _money(store.calc("", {}, _row(line, ()), None), "계")
|
||||
return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got
|
||||
|
||||
|
||||
def _codes(row: dict) -> list:
|
||||
return next(
|
||||
(i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), []
|
||||
)
|
||||
|
||||
|
||||
def one_value(item: dict, local: list[dict] | None = None) -> dict:
|
||||
"""물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`."""
|
||||
"""물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`.
|
||||
|
||||
기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈.
|
||||
"""
|
||||
group, ref = item.get("group"), item.get("ref")
|
||||
table = item.get("table") or {}
|
||||
table, field = item.get("table") or {}, item.get("field")
|
||||
if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref:
|
||||
ref, _, field = ref.partition(MACHINE_SEP)
|
||||
try:
|
||||
if isinstance(ref, str) and _UKEY.match(ref):
|
||||
got = _user(ref, local or [])
|
||||
@@ -358,9 +418,11 @@ def one_value(item: dict, local: list[dict] | None = None) -> dict:
|
||||
elif group == "기계" and str(ref or "").startswith("로직("):
|
||||
inputs = item.get("inputs") or {}
|
||||
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
|
||||
got = _money(store.calc("", inputs, _row(line, inputs), None), item.get("field"))
|
||||
got = _money(store.calc("", inputs, _row(line, inputs), None), field)
|
||||
elif group == "기계" and field == HOURLY:
|
||||
got = _machine_hourly(str(ref or ""), item.get("inputs") or {})
|
||||
elif group in ("기계", "유가전력", "환율", "요율"):
|
||||
got = _plain_value(group, str(ref or ""), item.get("field"))
|
||||
got = _plain_value(group, str(ref or ""), field)
|
||||
else:
|
||||
got = {"ok": False, "reason": f"모르는 그룹 「{group}」"}
|
||||
except store.StoreError as e:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { checkLogic, type CfEvalResult } from "./M02_CF_Eval";
|
||||
import { checkLogic, type CfEvalResult, type CfValues } from "./M02_CF_Eval";
|
||||
import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model";
|
||||
import { showValue } from "./M02_CF_OutputPanel";
|
||||
import "./M02_CF_Summary.css";
|
||||
@@ -109,12 +109,13 @@ function sampleField(
|
||||
return el("label", { className: "m01-cfsum__field", children: [label, control] });
|
||||
}
|
||||
|
||||
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */
|
||||
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
|
||||
export function renderSummary(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on?: { change(lg: CfLogic): void },
|
||||
opts?: { values?: CfValues },
|
||||
): void {
|
||||
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
|
||||
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
|
||||
@@ -187,7 +188,7 @@ export function renderSummary(
|
||||
/* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */
|
||||
let checked: string[] = [];
|
||||
try {
|
||||
checked = checkLogic(lg);
|
||||
checked = checkLogic(lg, opts?.values ?? {});
|
||||
} catch {
|
||||
checked = [];
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"y": 460,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "계수",
|
||||
"group": "소요량",
|
||||
"elements": [],
|
||||
"table": {
|
||||
"key": "QF000422"
|
||||
@@ -86,7 +86,7 @@
|
||||
"kind": "input",
|
||||
"name": "인력",
|
||||
"color": "#2f80ed",
|
||||
"unit": "-",
|
||||
"unit": "원/일",
|
||||
"x": 40,
|
||||
"y": 620,
|
||||
"input": {
|
||||
|
||||
@@ -219,3 +219,36 @@ def test_환율_유가전력_알약_이름에_날짜가_안_들어감():
|
||||
"값": 1900,
|
||||
}
|
||||
assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울"
|
||||
|
||||
|
||||
def test_단위가_안_맞게_저장된_상자는_열_때_맞춤():
|
||||
el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2)
|
||||
lg = logic()
|
||||
lg["boxes"][0]["input"] = {
|
||||
"mode": "group",
|
||||
"group": "인력",
|
||||
"elements": [el1, el2],
|
||||
"variables": [],
|
||||
}
|
||||
assert lg["boxes"][0]["unit"] == "-"
|
||||
fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]
|
||||
assert fixed["boxes"][0]["unit"] == "원/일"
|
||||
again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0]
|
||||
assert again == fixed
|
||||
oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"}
|
||||
lg["boxes"][0]["input"]["elements"].append(oil)
|
||||
lg["boxes"][0]["unit"] = "원/일"
|
||||
assert (
|
||||
run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"]
|
||||
== "-"
|
||||
)
|
||||
one = {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}],
|
||||
}
|
||||
lg["boxes"][0]["input"] = one
|
||||
assert (
|
||||
run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"]
|
||||
== "㎡"
|
||||
)
|
||||
|
||||
@@ -272,3 +272,77 @@ def test_알약_이름_규칙() -> None:
|
||||
"out(M.portsOut(lg.boxes[0]).map(p => p.name));"
|
||||
)
|
||||
assert got == ["시멘트", "표", "길이"]
|
||||
|
||||
|
||||
def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None:
|
||||
got = _run(
|
||||
ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n"
|
||||
"let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n"
|
||||
"let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n"
|
||||
"let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n"
|
||||
"let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n"
|
||||
"const gb = (x) => x.boxes.find(y => y.id === g.id);\n"
|
||||
"const inputs = gb(d.lg).func.inputs;\n"
|
||||
"const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n"
|
||||
"const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n"
|
||||
"const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n"
|
||||
"const removed = M.removeBox(d.lg, man.id);\n"
|
||||
"out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to),"
|
||||
" check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port),"
|
||||
" M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);"
|
||||
)
|
||||
a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got
|
||||
assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None)
|
||||
assert inputs == [
|
||||
{"port": "석공", "cell": ""},
|
||||
{"port": "면적", "cell": ""},
|
||||
{"port": "노무비", "cell": ""},
|
||||
]
|
||||
assert ports == ["석공", "면적", "노무비"]
|
||||
assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}]
|
||||
assert check == [
|
||||
"「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음",
|
||||
"「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음",
|
||||
"「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음",
|
||||
]
|
||||
assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음
|
||||
assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움
|
||||
assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음
|
||||
|
||||
|
||||
def test_같은_이름_받는_알약은_번호() -> None:
|
||||
got = _run(
|
||||
ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n"
|
||||
"let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n"
|
||||
"x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n"
|
||||
"r = add(x, 'input'); x = r.lg; const m2 = r.box;\n"
|
||||
"x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n"
|
||||
"x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n"
|
||||
"x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n"
|
||||
"out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));"
|
||||
)
|
||||
assert got == ["석공", "노무비", "석공 (2)", "할증"]
|
||||
|
||||
|
||||
def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None:
|
||||
got = _run(
|
||||
"let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n"
|
||||
"r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n"
|
||||
"r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n"
|
||||
"lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n"
|
||||
"lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n"
|
||||
"lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n"
|
||||
"r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n"
|
||||
"lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n"
|
||||
"lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n"
|
||||
"out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값]),"
|
||||
" lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);"
|
||||
)
|
||||
names, ports = got
|
||||
assert names == [
|
||||
["결과 1", "결과 1"],
|
||||
["결과 3", "결과 3"],
|
||||
["결과 2", "결과 2"],
|
||||
["결과 4", "결과 4"],
|
||||
]
|
||||
assert ports == ["결과 1", "결과 2", "결과 1 (2)"]
|
||||
|
||||
@@ -305,3 +305,49 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
assert client.get("/api/m02/cf/user-elements").status_code == 403
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
|
||||
assert [x["key"] for x in client.get("/api/m02/cf/list").json()["logics"]] == ["견본 돌쌓기"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None:
|
||||
"""입력 판(sub3) 기계 ref 「EQ키#칸」 · 물음 {ref, field} · 「시간당 경비」 = 기계경비 로직."""
|
||||
logic = min(
|
||||
(r for r in cf._machine_family() if "지역" in [i["이름"] for i in r["입력"]]),
|
||||
key=cf._machine_rank,
|
||||
)
|
||||
code = str(cf._codes(logic)[0])
|
||||
eq = _first("기계.json", lambda r: str(r.get("원문번호")) == code)
|
||||
family = [r for r in cf._machine_family() if code in [str(c) for c in cf._codes(r)]]
|
||||
chosen = min(family, key=cf._machine_rank)
|
||||
given = {
|
||||
i["이름"]: (code if i["이름"] == "기계" else (i.get("고르기") or [None])[0])
|
||||
for i in chosen["입력"]
|
||||
}
|
||||
want = _post(client, "/api/m01/calc", {"key": chosen["키"], "inputs": given})["sums"]["계"]
|
||||
|
||||
items = [
|
||||
{"id": "p", "group": "기계", "ref": f"{eq['키']}#가격"},
|
||||
{"id": "p2", "group": "기계", "ref": eq["키"], "field": "가격"},
|
||||
{"id": "h", "group": "기계", "ref": f"{eq['키']}#시간당 경비"},
|
||||
{"id": "h2", "group": "기계", "ref": eq["키"], "field": "시간당 경비"},
|
||||
{"id": "x", "group": "기계", "ref": f"{eq['키']}#없는칸"},
|
||||
]
|
||||
got = {v["id"]: v for v in _post(client, "/api/m02/cf/values", {"items": items})["values"]}
|
||||
assert got["p"]["value"] == got["p2"]["value"] == eq["값"]["가격"]
|
||||
assert got["h"]["value"] == got["h2"]["value"] == want and got["h"]["unit"] == "원/시간"
|
||||
assert got["x"]["ok"] is False and "없는칸" not in got["x"].get("value", "")
|
||||
|
||||
# 저장 — 입력 상자 요소 ref 그대로(「EQ키#칸」)
|
||||
box = _box("m1", "input", "기계", input={"mode": "group", "group": "기계", "variables": [],
|
||||
"elements": [{"ref": f"{eq['키']}#시간당 경비", "name": "기계 · 시간당 경비",
|
||||
"spec": "", "unit": "원/시간"}]}) # fmt: skip
|
||||
f1 = _box("f1", "function", "곱", func=_func(
|
||||
{"id": "s1", "이름": "곱", "칸": {"B1": {"식": "A1*2"}}},
|
||||
[{"port": "단가", "cell": "A1"}],
|
||||
{"cell": "B1", "name": "경비", "unit": "원"})) # fmt: skip
|
||||
o1 = _box("o1", "output", "경비", output={"cost": "경비", "unit": "원"})
|
||||
body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1],
|
||||
"links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"),
|
||||
_link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip
|
||||
saved = _post(client, "/api/m02/cf/save", {"logic": body})
|
||||
assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want)
|
||||
_post(client, "/api/m02/cf/delete", {"key": saved["key"]})
|
||||
|
||||
@@ -124,6 +124,9 @@ def test_견본_모양(name: str) -> None:
|
||||
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: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙)
|
||||
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
|
||||
@@ -224,6 +227,17 @@ def _run(lg: dict, master) -> dict:
|
||||
return {b["id"]: 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:
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
|
||||
Reference in New Issue
Block a user