Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_5
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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
valueOf,
|
||||
type M01Row,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
|
||||
import { renderMaterial } from "./M02_DF_InputPanel_Material";
|
||||
import { renderTablePick } from "./M02_DF_InputPanel_Table";
|
||||
import { renderVariables } from "./M02_DF_InputPanel_Vars";
|
||||
import "./M02_DF_InputPanel.css";
|
||||
@@ -62,6 +64,7 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise
|
||||
const TEXT = {
|
||||
Kind: ["무엇을 넣을까요?", "What goes in?"],
|
||||
Variable: ["변수", "Variable"],
|
||||
Logic: ["다른 로직", "Another logic"],
|
||||
인력: ["인력", "Labor"],
|
||||
재료: ["재료", "Material"],
|
||||
유가전력: ["유가·전력", "Fuel · power"],
|
||||
@@ -148,22 +151,26 @@ export function renderInputPanel(
|
||||
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 kind: CfGroup | "변수" | "다른 로직" =
|
||||
input.mode === "variable"
|
||||
? "변수"
|
||||
: input.mode === "logic"
|
||||
? "다른 로직"
|
||||
: (input.group ?? "인력");
|
||||
const body = el("div", { className: "m02-dfip__body" });
|
||||
|
||||
// ① 갈래 — 그룹 여덟 + 변수
|
||||
// ① 갈래 — 그룹 여덟 + 다른 로직 + 변수
|
||||
const kinds = el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
attrs: { role: "tablist", "aria-label": t("Kind") },
|
||||
children: [...GROUPS, "변수" as const].map((k) => {
|
||||
const b = el("button", {
|
||||
className: `m02-dfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`,
|
||||
attrs: { type: "button", role: "tab" },
|
||||
text: k === "변수" ? t("Variable") : t(k),
|
||||
});
|
||||
b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k)));
|
||||
return b;
|
||||
}),
|
||||
children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) =>
|
||||
createButton({
|
||||
label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k),
|
||||
variant: k === kind ? "filled" : "pill",
|
||||
size: "sm",
|
||||
onClick: () => k !== kind && on.change(setKind(lg, boxId, k)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const unit = el("p", {
|
||||
className: "m02-dfip__unit",
|
||||
@@ -172,7 +179,9 @@ export function renderInputPanel(
|
||||
host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body);
|
||||
|
||||
if (kind === "변수") renderVariables(body, lg, boxId, on);
|
||||
else if (kind === "다른 로직") renderLogicInput(body, lg, boxId, on);
|
||||
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
|
||||
else if (kind === "재료") renderMaterial(body, lg, boxId, on, (l, e) => addElement(l, boxId, e));
|
||||
else renderGroup(body, lg, box, kind, on);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_DF_Model";
|
||||
import type {
|
||||
CfBox,
|
||||
CfElementRef,
|
||||
CfGroup,
|
||||
CfInput,
|
||||
CfLogic,
|
||||
CfLogicRef,
|
||||
CfMaterialMode,
|
||||
CfMaterialRule,
|
||||
CfPort,
|
||||
CfScope,
|
||||
CfVariable,
|
||||
} from "./M02_DF_Model";
|
||||
|
||||
/** 입력 상자가 고르는 그룹 — 화면 차례 */
|
||||
export const GROUPS: CfGroup[] = [
|
||||
@@ -105,6 +117,7 @@ export function pillNames(box: CfBox): string[] {
|
||||
const i = box.input;
|
||||
if (!i) return [];
|
||||
if (i.table) return ["표"];
|
||||
if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name);
|
||||
return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name);
|
||||
}
|
||||
|
||||
@@ -117,9 +130,153 @@ export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
|
||||
return { group, ref };
|
||||
}
|
||||
|
||||
/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */
|
||||
|
||||
/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */
|
||||
|
||||
/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */
|
||||
export interface MatItem {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
구분?: string;
|
||||
상세구분?: string;
|
||||
}
|
||||
|
||||
const squash = (v: unknown): string =>
|
||||
String(v ?? "")
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */
|
||||
export function matches(
|
||||
item: Pick<MatItem, "name" | "spec">,
|
||||
rule: Pick<CfMaterialRule, "field" | "op">,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const v = squash(value);
|
||||
if (!v) return false;
|
||||
const target = squash(rule.field === "규격" ? item.spec : item.name);
|
||||
return rule.op === "같음" ? target === v : target.includes(v);
|
||||
}
|
||||
|
||||
/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */
|
||||
export function catchNote(
|
||||
found: number,
|
||||
firstName = "",
|
||||
): { kind: "none" | "one" | "many"; text: string } {
|
||||
if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" };
|
||||
if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` };
|
||||
return {
|
||||
kind: "many",
|
||||
text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */
|
||||
export function pageRange(
|
||||
total: number,
|
||||
page: number,
|
||||
size: number,
|
||||
): { from: number; to: number; pages: number } {
|
||||
const pages = Math.max(1, Math.ceil(total / size));
|
||||
const p = Math.min(Math.max(1, page), pages);
|
||||
return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages };
|
||||
}
|
||||
|
||||
/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */
|
||||
export function variablePills(
|
||||
lg: CfLogic,
|
||||
): { port: CfPort; unit: string; example: CfVariable["example"] }[] {
|
||||
return lg.boxes.flatMap((b) =>
|
||||
b.input?.mode === "variable"
|
||||
? b.input.variables.map((v) => ({
|
||||
port: { box: b.id, name: v.name },
|
||||
unit: v.unit,
|
||||
example: v.example,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */
|
||||
export function materialElement(
|
||||
mode: CfMaterialMode,
|
||||
name: string,
|
||||
scope: CfScope,
|
||||
extra: { rule?: CfMaterialRule; item?: Pick<MatItem, "ref" | "spec" | "unit"> } = {},
|
||||
): CfElementRef {
|
||||
const item = extra.item;
|
||||
return {
|
||||
ref: mode === "rule" ? "" : (item?.ref ?? ""),
|
||||
name: name.trim(),
|
||||
slot: name.trim(),
|
||||
spec: item?.spec ?? "",
|
||||
unit: item?.unit || "-",
|
||||
mode,
|
||||
scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope,
|
||||
...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}),
|
||||
...(mode === "choice" && item?.ref ? { default: item.ref } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
|
||||
export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null {
|
||||
if (!el.name) return "자리 이름을 적어 주세요";
|
||||
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
|
||||
if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
|
||||
if (el.mode !== "fixed" && !Object.keys(el.scope ?? {}).length)
|
||||
return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리) */
|
||||
export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
|
||||
const outputs = other.boxes
|
||||
.filter((b) => b.kind === "output")
|
||||
.map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" }));
|
||||
const designs: { name: string; unit: string }[] = [];
|
||||
for (const b of other.boxes) {
|
||||
if (b.kind !== "input" || !b.input) continue;
|
||||
if (b.input.mode === "variable")
|
||||
for (const v of b.input.variables) designs.push({ name: v.name, unit: v.unit || "-" });
|
||||
for (const e of b.input.elements)
|
||||
if (e.mode === "choice" || e.mode === "rule")
|
||||
designs.push({ name: e.slot || e.name, unit: e.unit || "-" });
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
key,
|
||||
name: other.name,
|
||||
outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)),
|
||||
designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))),
|
||||
};
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 그 알약에서 나간 선도 지움 */
|
||||
export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) => {
|
||||
if (b.id !== id || !b.input) return b;
|
||||
const input: CfInput = ref
|
||||
? { mode: "logic", elements: [], variables: [], logic: ref }
|
||||
: { mode: "variable", elements: [], variables: [] };
|
||||
return { ...b, input, unit: unitOfInput(input) };
|
||||
}),
|
||||
links: lg.links.filter((l) => l.from.box !== id),
|
||||
};
|
||||
}
|
||||
|
||||
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
|
||||
export function unitOfInput(input: CfInput): string {
|
||||
if (input.table) return "-";
|
||||
if (input.mode === "logic") {
|
||||
const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-");
|
||||
return u.length && u.every((x) => x === u[0]) ? u[0] : "-";
|
||||
}
|
||||
const units = (
|
||||
input.mode === "variable"
|
||||
? input.variables.map((v) => v.unit)
|
||||
@@ -179,12 +336,19 @@ const touch = (
|
||||
const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] };
|
||||
|
||||
/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */
|
||||
export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic {
|
||||
export function setKind(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
kind: CfGroup | "변수" | "다른 로직",
|
||||
pills?: Pills,
|
||||
): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => {
|
||||
const cur = inputOf(b);
|
||||
if (kind === "다른 로직")
|
||||
return { ...b, input: { mode: "logic", elements: [], variables: [] }, unit: "-" };
|
||||
if (kind === "변수")
|
||||
return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } };
|
||||
const same = cur.mode === "group" && cur.group === kind;
|
||||
@@ -204,7 +368,7 @@ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pi
|
||||
id,
|
||||
(b) => {
|
||||
const cur = inputOf(b);
|
||||
return cur.elements.some((e) => e.ref === el.ref || e.name === el.name)
|
||||
return cur.elements.some((e) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name)
|
||||
? b
|
||||
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
|
||||
},
|
||||
@@ -212,6 +376,19 @@ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pi
|
||||
);
|
||||
}
|
||||
|
||||
/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */
|
||||
export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => ({
|
||||
...b,
|
||||
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) },
|
||||
}),
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Logic.ts
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
Help: [
|
||||
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use a saved logic or sample as an input",
|
||||
],
|
||||
Search: ["로직 이름으로 찾기", "Search by name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 로직 없음", "No logic found"],
|
||||
Sample: ["견본", "Sample"],
|
||||
Mine: ["내가 만든 것", "Mine"],
|
||||
Chosen: ["고른 로직", "Chosen logic"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
|
||||
Designs: [
|
||||
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)",
|
||||
"Pills it takes — its design inputs",
|
||||
],
|
||||
NoOut: ["출력 상자가 없음", "No output box"],
|
||||
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
|
||||
Change: ["다른 로직 고르기", "Pick another"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Self: ["이 로직 자신은 못 씀", "Can't use itself"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
|
||||
|
||||
const plain = (e: unknown): string => {
|
||||
try {
|
||||
return cfPlain(e);
|
||||
} catch {
|
||||
return e instanceof Error ? e.message : t("Failed");
|
||||
}
|
||||
};
|
||||
|
||||
/** 다른 로직 입력 판 */
|
||||
export function renderLogicInput(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
|
||||
if (ref) return void showChosen(host, lg, boxId, ref, on);
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
let all: CfListItem[] = [];
|
||||
const draw = (): void => {
|
||||
const word = q.input.value.trim().toLowerCase();
|
||||
const rows = all.filter(
|
||||
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
|
||||
);
|
||||
list.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map((i) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: i.name }),
|
||||
el("span", {
|
||||
className: "m02-dfip__tag",
|
||||
text: i.sample ? t("Sample") : t("Mine"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pick(i));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
const pick = async (i: CfListItem): Promise<void> => {
|
||||
try {
|
||||
const other = await cfGet(i.key);
|
||||
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
|
||||
// 기본 이름이면 고른 로직 이름으로
|
||||
on.change({
|
||||
...next,
|
||||
boxes: next.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(plain(e), "error");
|
||||
}
|
||||
};
|
||||
q.input.addEventListener("input", draw);
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
||||
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
|
||||
void cfList().then(
|
||||
(items) => ((all = items), draw()),
|
||||
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
if (!ref) return;
|
||||
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__name", text: p.name }),
|
||||
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
|
||||
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
|
||||
...(ref.outputs.length
|
||||
? ref.outputs.map(pillLine)
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
|
||||
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
|
||||
...(ref.designs.length
|
||||
? ref.designs.map(pillLine)
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
children: [
|
||||
createButton({
|
||||
label: t("Change"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(setKind(lg, boxId, "다른 로직")),
|
||||
}),
|
||||
createButton({
|
||||
label: t("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(setLogicInput(lg, boxId, null)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Material.ts
|
||||
* 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수)
|
||||
* 서버 `/api/m02/df/materials` 를 먼저 부르고 아직 없으면 M01 `/pick?kind=price` 로 같은 모양을 만듦
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { cfValues } from "./M02_DF_Api";
|
||||
import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model";
|
||||
import {
|
||||
catchNote,
|
||||
checkMaterial,
|
||||
elementOf,
|
||||
matches,
|
||||
materialElement,
|
||||
pageRange,
|
||||
removeElementNamed,
|
||||
variablePills,
|
||||
type MatItem,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"],
|
||||
None: ["아직 고른 재료가 없음", "Nothing chosen yet"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Fixed: ["고정", "Fixed"],
|
||||
Choice: ["선택 자리", "Choice slot"],
|
||||
Rule: ["조건 찾기", "Find by rule"],
|
||||
FixedHelp: ["품목 하나를 못 박음", "One fixed item"],
|
||||
ChoiceHelp: [
|
||||
"자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)",
|
||||
"A slot — pick the item per project",
|
||||
],
|
||||
RuleHelp: [
|
||||
"변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관",
|
||||
"Find by a variable pill",
|
||||
],
|
||||
Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
||||
AllSub: ["전체", "All"],
|
||||
SubLabel: ["구분", "Group"],
|
||||
DetailLabel: ["상세구분", "Detail"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoRow: ["찾는 재료 없음", "Nothing found"],
|
||||
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
|
||||
Scope: ["범위", "Scope"],
|
||||
Word: ["찾는 말(범위 안에서)", "Word in scope"],
|
||||
Default: ["기본 품목", "Default item"],
|
||||
DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"],
|
||||
DefaultClear: ["기본 품목 비움", "Clear"],
|
||||
RuleField: ["찾는 칸", "Field"],
|
||||
FieldSpec: ["규격", "Spec"],
|
||||
FieldName: ["이름", "Name"],
|
||||
RuleOp: ["조건", "Condition"],
|
||||
OpIn: ["에 알약 값이 들어 있음", "contains the pill value"],
|
||||
OpEq: ["이 알약 값과 같음", "equals the pill value"],
|
||||
RulePill: ["변수 알약", "Variable pill"],
|
||||
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
|
||||
Put: ["넣기", "Add"],
|
||||
Pick: ["넣기", "Add"],
|
||||
Preview: ["지금 잡히는 재료", "Items caught now"],
|
||||
Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"],
|
||||
RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"],
|
||||
ScopeLine: ["범위 {s}", "scope {s}"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
||||
|
||||
/* ── 자재 찾기 ── */
|
||||
export interface MatQuery {
|
||||
q: string;
|
||||
sub?: string;
|
||||
detail?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
export interface MatPage {
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
items: MatItem[];
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** 서버가 없으면 M01 값 줄에서 같은 모양으로 — 띄어쓰기 AND(이름 + 규격) · 쪽은 앞에서 잘라 줌 */
|
||||
async function fallback(q: MatQuery): Promise<MatPage> {
|
||||
const r = await getJson<{ items: Record<string, unknown>[] }>(
|
||||
`/m01/pick?${new URLSearchParams({ kind: "price", q: q.q, limit: "200" })}`,
|
||||
);
|
||||
const rows = r.items.filter((i) => !q.sub || i["구분"] === q.sub);
|
||||
const items = rows.map((i): MatItem => ({
|
||||
ref: String(i["ref"]),
|
||||
name: String(i["이름"] ?? ""),
|
||||
spec: String(i["규격"] ?? ""),
|
||||
unit: String(i["단위"] ?? ""),
|
||||
price: typeof i["값"] === "number" ? i["값"] : null,
|
||||
구분: String(i["구분"] ?? ""),
|
||||
}));
|
||||
const from = (q.page - 1) * q.size;
|
||||
return {
|
||||
total: items.length,
|
||||
page: q.page,
|
||||
size: q.size,
|
||||
items: items.slice(from, from + q.size),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchMaterials(q: MatQuery): Promise<MatPage> {
|
||||
const params = new URLSearchParams({
|
||||
q: q.q,
|
||||
page: String(q.page),
|
||||
size: String(q.size),
|
||||
sub: q.sub ?? "",
|
||||
detail: q.detail ?? "",
|
||||
});
|
||||
try {
|
||||
return await getJson<MatPage>(`/m02/df/materials?${params}`);
|
||||
} catch {
|
||||
return fallback(q);
|
||||
}
|
||||
}
|
||||
|
||||
let subsCache: SubInfo[] | null = null;
|
||||
async function materialSubs(): Promise<SubInfo[]> {
|
||||
if (subsCache) return subsCache;
|
||||
const files = await fetchFiles("재료");
|
||||
const f = files.find((x) => x.file.includes("자재품목")) ?? files[0];
|
||||
subsCache = await fetchSubs({ file: f.file });
|
||||
return subsCache;
|
||||
}
|
||||
|
||||
/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */
|
||||
function scopeBoxes(
|
||||
init: { sub: string; detail: string },
|
||||
onChange: () => void,
|
||||
): { root: HTMLElement; get(): { sub: string; detail: string } } {
|
||||
const state = { ...init };
|
||||
const all = { value: "", text: t("AllSub") };
|
||||
const subBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: t("SubLabel"),
|
||||
onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: t("DetailLabel"),
|
||||
onChange: (v) => ((state.detail = v), onChange()),
|
||||
});
|
||||
let subs: SubInfo[] = [];
|
||||
const fill = (): void => {
|
||||
const d = subs.find((s) => s.name === state.sub)?.details ?? [];
|
||||
detailBox.root.hidden = !d.length;
|
||||
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail);
|
||||
};
|
||||
fill();
|
||||
void materialSubs().then(
|
||||
(s) => {
|
||||
subs = s;
|
||||
subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub);
|
||||
fill();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return {
|
||||
root: el("div", { className: "m02-dfip__bar", children: [subBox.root, detailBox.root] }),
|
||||
get: () => ({ ...state }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */
|
||||
function itemList(
|
||||
onPick: (item: MatItem) => void,
|
||||
base?: () => { sub: string; detail: string },
|
||||
): HTMLElement {
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const info = el("span", { className: "m02-dfip__muted" });
|
||||
let page = 1;
|
||||
let seq = 0;
|
||||
const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load()));
|
||||
const scope = (): { sub: string; detail: string } => (base ? base() : own!.get());
|
||||
const prev = createButton({
|
||||
label: t("Prev"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page -= 1), void load()),
|
||||
});
|
||||
const next = createButton({
|
||||
label: t("Next"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page += 1), void load()),
|
||||
});
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
||||
try {
|
||||
const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 });
|
||||
if (mine !== seq) return;
|
||||
const range = pageRange(r.total, page, 12);
|
||||
info.textContent = t("Total", {
|
||||
n: r.total,
|
||||
a: range.from,
|
||||
b: range.to,
|
||||
p: Math.min(page, range.pages),
|
||||
m: range.pages,
|
||||
});
|
||||
prev.disabled = page <= 1;
|
||||
next.disabled = page >= range.pages;
|
||||
list.replaceChildren(
|
||||
...(r.items.length
|
||||
? r.items.map((it) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: it.name }),
|
||||
el("span", { className: "m02-dfip__muted", text: ` ${it.spec}` }),
|
||||
],
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-dfip__muted",
|
||||
text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => onPick(it));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq)
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Failed") }));
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (
|
||||
window.clearTimeout(timer),
|
||||
(timer = window.setTimeout(() => ((page = 1), void load()), 250))
|
||||
),
|
||||
);
|
||||
void load();
|
||||
return el("div", {
|
||||
className: "m02-dfip__find",
|
||||
children: [
|
||||
...(own ? [own.root] : []),
|
||||
q.root,
|
||||
info,
|
||||
list,
|
||||
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 잡히는 재료 미리 보기 ── */
|
||||
export async function previewOf(
|
||||
el1: CfElementRef,
|
||||
lg: CfLogic,
|
||||
): Promise<{ found: number; names: string[]; cheapest: MatItem | null }> {
|
||||
const scope = el1.scope ?? {};
|
||||
const word = scope.찾는말 ?? "";
|
||||
if (el1.mode === "rule" && el1.rule) {
|
||||
const pill = variablePills(lg).find(
|
||||
(p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name,
|
||||
);
|
||||
const value = pill?.example;
|
||||
if (value === null || value === undefined || value === "")
|
||||
return { found: 0, names: [], cheapest: null };
|
||||
const r = await searchMaterials({
|
||||
q: `${word} ${value}`.trim(),
|
||||
sub: scope.구분,
|
||||
detail: scope.상세구분,
|
||||
page: 1,
|
||||
size: 200,
|
||||
});
|
||||
const hit = r.items.filter((i) => matches(i, el1.rule!, value));
|
||||
return {
|
||||
found: hit.length,
|
||||
names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(hit),
|
||||
};
|
||||
}
|
||||
const r = await searchMaterials({
|
||||
q: word,
|
||||
sub: scope.구분,
|
||||
detail: scope.상세구분,
|
||||
page: 1,
|
||||
size: 200,
|
||||
});
|
||||
return {
|
||||
found: r.total,
|
||||
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(r.items),
|
||||
};
|
||||
}
|
||||
|
||||
const cheapestOf = (items: MatItem[]): MatItem | null =>
|
||||
items
|
||||
.filter((i) => i.price !== null)
|
||||
.sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null;
|
||||
|
||||
/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */
|
||||
function summaryOf(e: CfElementRef, lg: CfLogic): string {
|
||||
const parts: string[] = [];
|
||||
const scope = Object.entries(e.scope ?? {})
|
||||
.map(([, v]) => v)
|
||||
.join(" / ");
|
||||
if (scope) parts.push(t("ScopeLine", { s: scope }));
|
||||
if (e.rule) {
|
||||
const p = variablePills(lg).find(
|
||||
(x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name,
|
||||
);
|
||||
parts.push(
|
||||
t("RuleLine", {
|
||||
f: e.rule.field,
|
||||
o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"),
|
||||
n: e.rule.from.name,
|
||||
v: p?.example ?? "—",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 재료 판 — 넣은 재료 목록 + 방식 셋 */
|
||||
export function renderMaterial(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
const els = (box?.input?.elements ?? []) as CfElementRef[];
|
||||
const chosen = el("div", { className: "m02-dfip__chosen" });
|
||||
const body = el("div", { className: "m02-dfip__stack" });
|
||||
let mode: CfMaterialMode = "fixed";
|
||||
|
||||
const drawChosen = (): void => {
|
||||
chosen.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} · ${els.length}` }),
|
||||
...(els.length
|
||||
? els.map((e) => {
|
||||
const val = el("strong", { className: "m02-dfip__value", text: "—" });
|
||||
const note = el("span", { className: "m02-dfip__muted" });
|
||||
const kind =
|
||||
e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed");
|
||||
const row = el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__tag", text: kind }),
|
||||
el("strong", { text: ` ${e.name}` }),
|
||||
el("div", { className: "m02-dfip__muted", text: summaryOf(e, lg) }),
|
||||
note,
|
||||
],
|
||||
}),
|
||||
val,
|
||||
el("span", { className: "m02-dfip__muted", text: e.unit || "-" }),
|
||||
createButton({
|
||||
label: t("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(removeElementNamed(lg, boxId, e.name)),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref;
|
||||
if (ref)
|
||||
void cfValues([{ id: ref, group: "재료", ref }] as never)
|
||||
.then((a) =>
|
||||
a.forEach(
|
||||
(x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
if (e.mode && e.mode !== "fixed")
|
||||
void previewOf(e, lg).then(
|
||||
(p) => {
|
||||
const n = catchNote(p.found, p.names[0]);
|
||||
note.textContent = `${t("Preview")}: ${n.text}`;
|
||||
note.classList.toggle("is-bad", n.kind !== "one");
|
||||
if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
|
||||
const chips = el("div", { className: "m02-dfip__chips" });
|
||||
const drawChips = (): void =>
|
||||
chips.replaceChildren(
|
||||
...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) =>
|
||||
createButton({
|
||||
label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"),
|
||||
variant: mode === m ? "filled" : "pill",
|
||||
size: "sm",
|
||||
onClick: () => ((mode = m), drawChips(), drawBody()),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const drawBody = (): void => {
|
||||
if (mode === "fixed") {
|
||||
body.replaceChildren(
|
||||
el("p", { className: "m02-dfip__muted", text: t("FixedHelp") }),
|
||||
itemList((it) =>
|
||||
on.change(
|
||||
addEl(lg, {
|
||||
...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }),
|
||||
mode: "fixed",
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기
|
||||
const name = createInputField({
|
||||
type: "text",
|
||||
label: t("SlotName"),
|
||||
compact: true,
|
||||
placeholder: "예: 흄관",
|
||||
});
|
||||
const word = createInputField({ type: "text", label: t("Word"), compact: true });
|
||||
let def: MatItem | null = null;
|
||||
const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh());
|
||||
const note = el("p", { className: "m02-dfip__note" });
|
||||
const pills = variablePills(lg);
|
||||
const fieldBox = createSelectField({
|
||||
label: t("RuleField"),
|
||||
options: [
|
||||
{ value: "규격", text: t("FieldSpec") },
|
||||
{ value: "이름", text: t("FieldName") },
|
||||
],
|
||||
value: "규격",
|
||||
compact: true,
|
||||
onChange: () => void refresh(),
|
||||
});
|
||||
const opBox = createSelectField({
|
||||
label: t("RuleOp"),
|
||||
options: [
|
||||
{ value: "포함", text: t("OpIn") },
|
||||
{ value: "같음", text: t("OpEq") },
|
||||
],
|
||||
value: "포함",
|
||||
compact: true,
|
||||
onChange: () => void refresh(),
|
||||
});
|
||||
const pillBox = createSelectField({
|
||||
label: t("RulePill"),
|
||||
options: pills.map((p, i) => ({
|
||||
value: String(i),
|
||||
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
|
||||
})),
|
||||
value: "0",
|
||||
compact: true,
|
||||
onChange: () => void refresh(),
|
||||
});
|
||||
const defNote = el("p", { className: "m02-dfip__muted" });
|
||||
const build = (): CfElementRef => {
|
||||
const sc = scope.get();
|
||||
const rule: CfMaterialRule | undefined =
|
||||
mode === "rule" && pills[Number(pillBox.select.value)]
|
||||
? {
|
||||
field: fieldBox.select.value as CfMaterialRule["field"],
|
||||
op: opBox.select.value as CfMaterialRule["op"],
|
||||
from: pills[Number(pillBox.select.value)].port,
|
||||
}
|
||||
: undefined;
|
||||
return materialElement(
|
||||
mode,
|
||||
name.input.value,
|
||||
{ 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() },
|
||||
{ rule, item: def ?? undefined },
|
||||
);
|
||||
};
|
||||
let seq = 0;
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone");
|
||||
const cand = build();
|
||||
note.textContent = `${t("Preview")}: …`;
|
||||
try {
|
||||
const p = await previewOf(cand, lg);
|
||||
if (mine !== seq) return;
|
||||
const n = catchNote(p.found, p.names[0]);
|
||||
note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`;
|
||||
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
|
||||
} catch {
|
||||
if (mine === seq) note.textContent = t("Failed");
|
||||
}
|
||||
};
|
||||
name.input.addEventListener("input", () => name.setError(undefined));
|
||||
word.input.addEventListener("input", () => void refresh());
|
||||
const add = createButton({
|
||||
label: t("Put"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const cand = build();
|
||||
const why = checkMaterial(cand, els);
|
||||
name.setError(why ?? undefined);
|
||||
if (why) return void showToast(why, "error");
|
||||
on.change(addEl(lg, cand));
|
||||
},
|
||||
});
|
||||
const clear = createButton({
|
||||
label: t("DefaultClear"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((def = null), void refresh()),
|
||||
});
|
||||
body.replaceChildren(
|
||||
el("p", {
|
||||
className: "m02-dfip__muted",
|
||||
text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"),
|
||||
}),
|
||||
name.root,
|
||||
el("h5", { text: t("Scope") }),
|
||||
scope.root,
|
||||
word.root,
|
||||
...(mode === "rule"
|
||||
? pills.length
|
||||
? [fieldBox.root, opBox.root, pillBox.root]
|
||||
: [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]
|
||||
: [
|
||||
el("h5", { text: t("Default") }),
|
||||
defNote,
|
||||
clear,
|
||||
itemList((it) => ((def = it), void refresh())),
|
||||
]),
|
||||
note,
|
||||
add,
|
||||
);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
host.replaceChildren(chosen, chips, body);
|
||||
drawChosen();
|
||||
drawChips();
|
||||
drawBody();
|
||||
}
|
||||
@@ -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 {
|
||||
/** 파일 이름(확장자 뺌) */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""단가산출 흐름도 입력 판 — 재료 요소 방식 셋 · 쪽 넘기기 · 다른 로직 입력(`M02_DF_InputPanel_Build.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 상자 · 로직은 이 시험 안에 둠.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(jobs.map((j) => m[j.name](...j.args))));
|
||||
"""
|
||||
|
||||
|
||||
def call(name: str, *args):
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)[0]
|
||||
|
||||
|
||||
PLAIN = {"color": "#fff", "unit": "-", "x": 0, "y": 0}
|
||||
|
||||
|
||||
def logic() -> dict:
|
||||
return {
|
||||
"key": None,
|
||||
"name": "견본",
|
||||
"resultUnit": "원/㎡",
|
||||
"userElements": [],
|
||||
"boxes": [
|
||||
{
|
||||
"id": "b1",
|
||||
"kind": "input",
|
||||
"name": "입력",
|
||||
**PLAIN,
|
||||
"input": {"mode": "variable", "elements": [], "variables": []},
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"kind": "input",
|
||||
"name": "관경",
|
||||
**PLAIN,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [{"name": "관경", "type": "text", "unit": "", "example": "300"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
|
||||
HUME = {"name": "각형흄관", "spec": "D=300mm"}
|
||||
|
||||
|
||||
def test_규칙이_품목을_잡음_규격_이름_포함_같음():
|
||||
in_spec = {"field": "규격", "op": "포함"}
|
||||
assert call("matches", HUME, in_spec, "300") is True
|
||||
assert call("matches", HUME, in_spec, "400") is False
|
||||
assert call("matches", HUME, {"field": "이름", "op": "포함"}, "흄관") is True
|
||||
assert call("matches", HUME, {"field": "이름", "op": "같음"}, "흄관") is False
|
||||
assert (
|
||||
call("matches", HUME, {"field": "이름", "op": "같음"}, "각형 흄관") is True
|
||||
) # 띄어쓰기는 가리지 않음
|
||||
assert call("matches", HUME, in_spec, "") is False # 알약 값이 비면 안 잡음
|
||||
|
||||
|
||||
def test_잡힌_수에_따른_알림():
|
||||
assert call("catchNote", 0)["kind"] == "none"
|
||||
one = call("catchNote", 1, "각형흄관 D=300mm")
|
||||
assert one["kind"] == "one" and "각형흄관" in one["text"]
|
||||
many = call("catchNote", 3)
|
||||
assert many["kind"] == "many" and "3 개" in many["text"]
|
||||
|
||||
|
||||
def test_쪽_넘기기_범위():
|
||||
assert call("pageRange", 25, 1, 12) == {"from": 1, "to": 12, "pages": 3}
|
||||
assert call("pageRange", 25, 3, 12) == {"from": 25, "to": 25, "pages": 3}
|
||||
assert call("pageRange", 25, 9, 12) == {
|
||||
"from": 25,
|
||||
"to": 25,
|
||||
"pages": 3,
|
||||
} # 쪽이 넘치면 마지막 쪽
|
||||
assert call("pageRange", 0, 1, 12) == {"from": 0, "to": 0, "pages": 1}
|
||||
|
||||
|
||||
def test_변수_알약_목록():
|
||||
pills = call("variablePills", logic())
|
||||
assert pills == [{"port": {"box": "b2", "name": "관경"}, "unit": "", "example": "300"}]
|
||||
|
||||
|
||||
def test_선택_자리와_조건_찾기_요소():
|
||||
item = {"ref": "MT009686", "spec": "D=300mm", "unit": "M"}
|
||||
slot = call(
|
||||
"materialElement",
|
||||
"choice",
|
||||
"흄관",
|
||||
{"구분": "관·이음관", "상세구분": "", "찾는말": "흄관"},
|
||||
{"item": item},
|
||||
)
|
||||
assert slot["mode"] == "choice" and slot["name"] == "흄관" and slot["slot"] == "흄관"
|
||||
assert slot["default"] == "MT009686" and slot["scope"] == {
|
||||
"구분": "관·이음관",
|
||||
"찾는말": "흄관",
|
||||
}
|
||||
rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}}
|
||||
found = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule})
|
||||
assert found["mode"] == "rule" and found["ref"] == "" and found["rule"] == rule
|
||||
assert "default" not in found
|
||||
|
||||
|
||||
def test_재료_요소_검사():
|
||||
ok = call("materialElement", "choice", "흄관", {"찾는말": "흄관"})
|
||||
assert call("checkMaterial", ok, []) is None
|
||||
assert call("checkMaterial", {**ok, "name": ""}, []) == "자리 이름을 적어 주세요"
|
||||
assert call("checkMaterial", ok, [ok]) == "같은 이름의 알약이 이미 있음"
|
||||
assert "범위" in call("checkMaterial", call("materialElement", "choice", "흄관", {}), [])
|
||||
rule_no_pill = call(
|
||||
"materialElement",
|
||||
"rule",
|
||||
"흄관",
|
||||
{"찾는말": "흄관"},
|
||||
{"rule": {"field": "규격", "op": "포함", "from": {"box": "", "name": ""}}},
|
||||
)
|
||||
assert "변수 알약" in call("checkMaterial", rule_no_pill, [])
|
||||
|
||||
|
||||
def test_조건_찾기_요소는_품목_키가_없어도_따로_넣고_이름으로_뺌():
|
||||
rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}}
|
||||
a = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule})
|
||||
b = call("materialElement", "rule", "받침", {"찾는말": "받침"}, {"rule": rule})
|
||||
lg = call("addElement", logic(), "b1", a)
|
||||
lg = call("addElement", lg, "b1", b)
|
||||
assert [e["name"] for e in lg["boxes"][0]["input"]["elements"]] == ["흄관", "받침"]
|
||||
assert call("addElement", lg, "b1", a) == lg # 같은 이름은 한 번만
|
||||
gone = call("removeElementNamed", lg, "b1", "흄관")
|
||||
assert [e["name"] for e in gone["boxes"][0]["input"]["elements"]] == ["받침"]
|
||||
|
||||
|
||||
def other_logic() -> dict:
|
||||
return {
|
||||
"key": "DF-0009",
|
||||
"name": "시간당 작업량",
|
||||
"resultUnit": "㎥/시간",
|
||||
"userElements": [],
|
||||
"links": [],
|
||||
"boxes": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "input",
|
||||
"name": "조건",
|
||||
**PLAIN,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [{"name": "q", "type": "number", "unit": "㎥", "example": 6}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "i2",
|
||||
"kind": "input",
|
||||
"name": "재료",
|
||||
**PLAIN,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "재료",
|
||||
"elements": [
|
||||
{
|
||||
"ref": "",
|
||||
"name": "흄관",
|
||||
"spec": "",
|
||||
"unit": "M",
|
||||
"mode": "rule",
|
||||
"slot": "흄관",
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "o1",
|
||||
"kind": "output",
|
||||
"name": "작업량",
|
||||
**PLAIN,
|
||||
"unit": "㎥/시간",
|
||||
"output": {"cost": "값", "unit": "㎥/시간"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_다른_로직_입력_알약():
|
||||
ref = call("logicRefOf", "DF-0009", other_logic())
|
||||
assert ref["key"] == "DF-0009" and ref["name"] == "시간당 작업량"
|
||||
assert ref["outputs"] == [{"name": "작업량", "unit": "㎥/시간"}]
|
||||
assert ref["designs"] == [{"name": "q", "unit": "㎥"}, {"name": "흄관", "unit": "M"}]
|
||||
|
||||
|
||||
def test_다른_로직_상자는_출력_단위를_따르고_빼면_변수로_돌아감():
|
||||
ref = call("logicRefOf", "DF-0009", other_logic())
|
||||
lg = call("setLogicInput", logic(), "b1", ref)
|
||||
box = lg["boxes"][0]
|
||||
assert box["input"]["mode"] == "logic" and box["input"]["logic"]["key"] == "DF-0009"
|
||||
assert box["unit"] == "㎥/시간"
|
||||
assert call("pillNames", box) == ["작업량"]
|
||||
lg["links"] = [
|
||||
{"id": "l1", "from": {"box": "b1", "name": "작업량"}, "to": {"box": "b2", "name": "x"}}
|
||||
]
|
||||
back = call("setLogicInput", lg, "b1", None)
|
||||
assert back["boxes"][0]["input"]["mode"] == "variable" and back["links"] == []
|
||||
assert call("reconcileUnit", lg, "b1") == lg # 다른 로직 상자 단위는 안 건드림
|
||||
|
||||
|
||||
def test_갈래를_다른_로직으로_바꾸면_고른_것을_비움():
|
||||
el = {"ref": "LB1", "name": "석공", "spec": "", "unit": "원/일"}
|
||||
lg = call("addElement", call("setKind", logic(), "b1", "인력"), "b1", el)
|
||||
out = call("setKind", lg, "b1", "다른 로직")
|
||||
assert (
|
||||
out["boxes"][0]["input"]["mode"] == "logic" and out["boxes"][0]["input"]["elements"] == []
|
||||
)
|
||||
@@ -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