Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
.m01-cfpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-cfpanel-head,
|
||||
.m01-cfpanel-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-cfpanel-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.m01-cfpanel-title {
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m01-cfpanel-unitbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m01-cfpanel-unit {
|
||||
width: 6em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m01-cfpanel-cell {
|
||||
padding: 2px var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-cfpanel-label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-cfpanel-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfpanel .ui-btn,
|
||||
.m01-cfpanel .ui-input,
|
||||
.m01-cfpanel .ui-select {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-cfpanel-form {
|
||||
align-items: flex-end;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-cfpanel-fieldbox {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m01-cfpanel-field {
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.m01-cfpanel-field.is-num {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.m01-cfpanel-why,
|
||||
.m01-cfpanel-msg {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-cfpanel-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 2px solid;
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
button.m01-cfpanel-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.m01-cfpanel-chip:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-cfpanel-chip.is-in {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.m01-cfpanel-chip.is-in:not(.is-placed) {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01-cfpanel-chip.is-out {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.m01-cfpanel-chip.is-var {
|
||||
border-color: #22a06b;
|
||||
}
|
||||
|
||||
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
|
||||
.m01-cfpanel-sheet {
|
||||
position: relative;
|
||||
height: max(640px, calc(100vh - 260px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */
|
||||
.m01-cfpanel .aislo-cell[data-cf-kind="in"] {
|
||||
box-shadow: inset 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.m01-cfpanel .aislo-cell[data-cf-kind="var"] {
|
||||
box-shadow: inset 0 0 0 2px #22a06b;
|
||||
}
|
||||
|
||||
.m01-cfpanel .aislo-cell[data-cf-kind="out"] {
|
||||
box-shadow: inset 0 0 0 2px #f59e0b;
|
||||
}
|
||||
|
||||
.m01-cfpanel .aislo-cell[data-cf-kind]::after {
|
||||
content: attr(data-cf-name);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 0 0 0 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m01-cfpanel .aislo-cell[data-cf-lock]::before {
|
||||
content: attr(data-cf-lock);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0 4px;
|
||||
background: var(--color-surface);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m01-cfpanel .aislo-cell[data-cf-err] {
|
||||
outline: 2px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_FuncPanel.ts
|
||||
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
|
||||
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
|
||||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_CF_Model 타입 그대로
|
||||
* 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
CalcValues,
|
||||
SpreadsheetHandle,
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_CF_Model";
|
||||
import "./M02_CF_FuncPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
Unit: ["단위", "Unit"],
|
||||
Cell: ["고른 칸", "Selected cell"],
|
||||
Got: ["받은 알약", "Received pills"],
|
||||
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
|
||||
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
|
||||
Placed: ["놓임", "placed"],
|
||||
Free: ["놓을 칸 없음", "not placed"],
|
||||
Busy: ["이미 쓰는 칸", "That cell is already in use"],
|
||||
Locked: [
|
||||
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
|
||||
"This cell is filled by the wire — cannot edit",
|
||||
],
|
||||
Vars: ["변수", "Variables"],
|
||||
MakeVar: ["변수 만들기", "Make variable"],
|
||||
NoVars: ["아직 변수가 없음", "No variables yet"],
|
||||
VarName: ["이름", "Name"],
|
||||
VarType: ["갈래", "Type"],
|
||||
Number: ["숫자", "Number"],
|
||||
Choice: ["선택", "Choice"],
|
||||
Text: ["글", "Text"],
|
||||
VarUnit: ["단위", "Unit"],
|
||||
VarMin: ["최소", "Min"],
|
||||
VarMax: ["최대", "Max"],
|
||||
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
|
||||
VarExample: ["예시값", "Example value"],
|
||||
VarCell: ["붙을 칸", "Attach to"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Remove: ["지움", "Remove"],
|
||||
NeedName: ["이름을 적음", "Enter a name"],
|
||||
SameName: ["같은 이름이 이미 있음", "That name already exists"],
|
||||
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
|
||||
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
|
||||
OutBtn: ["출력 칸으로", "Make output cell"],
|
||||
OutNow: ["출력", "Output"],
|
||||
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
|
||||
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
|
||||
Frames: ["틀", "Templates"],
|
||||
FrameMul: ["곱하기", "Multiply"],
|
||||
FrameMore: ["할증", "Markup"],
|
||||
FrameDiv: ["나누기", "Divide"],
|
||||
FrameIf: ["만약", "If"],
|
||||
FrameMin: ["최소", "Min"],
|
||||
FrameMax: ["최대", "Max"],
|
||||
FrameUp: ["올림", "Round up"],
|
||||
FrameLook: ["표에서 찾기", "Look up in table"],
|
||||
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
||||
ReadOnly: ["읽기만", "Read only"],
|
||||
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
||||
Problem: ["⚠ ", "⚠ "],
|
||||
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
|
||||
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
||||
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
||||
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
||||
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
||||
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
||||
Err_NA: ["찾는 값이 없음", "value not found"],
|
||||
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
||||
} as const;
|
||||
|
||||
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
||||
const FRAMES: [keyof typeof TEXT, string][] = [
|
||||
["FrameMul", "=B1*B2"],
|
||||
["FrameMore", "=B1*(1+B2/100)"],
|
||||
["FrameDiv", "=B1/B2"],
|
||||
["FrameIf", '=IF(B3="급",10,0)'],
|
||||
["FrameMin", "=MIN(B1,B2)"],
|
||||
["FrameMax", "=MAX(B1,B2)"],
|
||||
["FrameUp", "=ROUNDUP(B4,3)"],
|
||||
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
||||
];
|
||||
|
||||
const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
||||
"#DIV/0!": "Err_DIV0",
|
||||
"#NAME?": "Err_NAME",
|
||||
"#REF!": "Err_REF",
|
||||
"#VALUE!": "Err_VALUE",
|
||||
"#CYCLE!": "Err_CYCLE",
|
||||
"#N/A": "Err_NA",
|
||||
};
|
||||
|
||||
type Handlers = { change(lg: CfLogic): void };
|
||||
|
||||
interface VarForm {
|
||||
name: string;
|
||||
type: CfVariable["type"];
|
||||
unit: string;
|
||||
min: string;
|
||||
max: string;
|
||||
choices: string;
|
||||
example: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
boxId: string;
|
||||
lg: CfLogic;
|
||||
on: Handlers;
|
||||
readOnly: boolean;
|
||||
handle: SpreadsheetHandle | null;
|
||||
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
||||
snap: CalcValues;
|
||||
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
|
||||
json: string;
|
||||
cell: string;
|
||||
form: VarForm | null;
|
||||
parts: {
|
||||
head: HTMLElement;
|
||||
got: HTMLElement;
|
||||
vars: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
msg: HTMLElement;
|
||||
sheet: HTMLElement;
|
||||
};
|
||||
observer: MutationObserver | null;
|
||||
redraw(): void;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, State>();
|
||||
let sheetModule: Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> | null = null;
|
||||
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
|
||||
const loadSheet = (): Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> =>
|
||||
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
|
||||
|
||||
const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId);
|
||||
const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func;
|
||||
const bookOf = (func: CfFunc): Workbook => ({
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "cf",
|
||||
서식: [{}],
|
||||
시트: [structuredClone(func.sheet)],
|
||||
활성: func.sheet.id,
|
||||
});
|
||||
const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet);
|
||||
|
||||
/* ------------------------------ 바꾸기 ------------------------------ */
|
||||
|
||||
function patchBox(st: State, make: (b: CfBox) => CfBox): void {
|
||||
const next: CfLogic = {
|
||||
...st.lg,
|
||||
boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)),
|
||||
};
|
||||
st.lg = next;
|
||||
st.on.change(next);
|
||||
}
|
||||
|
||||
const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void =>
|
||||
patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b));
|
||||
|
||||
const usedCells = (f: CfFunc): Map<string, string> => {
|
||||
const m = new Map<string, string>();
|
||||
for (const i of f.inputs) m.set(i.cell, i.port);
|
||||
for (const v of f.variables) if (v.cell) m.set(v.cell, v.name);
|
||||
if (f.output.cell) m.set(f.output.cell, f.output.name);
|
||||
return m;
|
||||
};
|
||||
|
||||
function placePort(st: State, port: string, cell: string): void {
|
||||
const f = funcOf(st);
|
||||
if (!f || st.readOnly) return;
|
||||
const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell);
|
||||
if (other) return void showToast(tf("Busy"), "error");
|
||||
patchFunc(st, (x) => ({
|
||||
...x,
|
||||
inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }],
|
||||
}));
|
||||
}
|
||||
|
||||
function makeVariable(st: State): void {
|
||||
const form = st.form;
|
||||
const f = funcOf(st);
|
||||
if (!form || !f) return;
|
||||
const name = form.name.trim();
|
||||
const fail = (key: keyof typeof TEXT): void => {
|
||||
form.why = tf(key);
|
||||
drawVars(st);
|
||||
};
|
||||
if (!name) return fail("NeedName");
|
||||
const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)];
|
||||
const mine = f.variables.find((v) => v.name === name);
|
||||
if (taken.includes(name) && !mine) return fail("SameName");
|
||||
const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) });
|
||||
if (busy.has(st.cell)) return fail("Busy");
|
||||
let example: number | string | null = form.example.trim() === "" ? null : form.example.trim();
|
||||
const v: CfVariable = {
|
||||
name,
|
||||
type: form.type,
|
||||
unit: form.unit.trim() || "-",
|
||||
example: null,
|
||||
cell: st.cell,
|
||||
};
|
||||
if (form.type === "number") {
|
||||
if (example !== null) {
|
||||
const n = Number(String(example).replace(/,/g, ""));
|
||||
if (!Number.isFinite(n)) return fail("NotNumber");
|
||||
example = n;
|
||||
}
|
||||
const lo = form.min.trim() === "" ? null : Number(form.min);
|
||||
const hi = form.max.trim() === "" ? null : Number(form.max);
|
||||
if (lo !== null && hi !== null) {
|
||||
if (!(lo < hi)) return fail("BadRange");
|
||||
v.range = [lo, hi];
|
||||
}
|
||||
} else if (form.type === "choice") {
|
||||
v.choices = form.choices
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
v.example = example;
|
||||
const cell = st.cell;
|
||||
st.form = null;
|
||||
patchFunc(st, (x) => {
|
||||
const sheet = structuredClone(x.sheet);
|
||||
if (example !== null) sheet.칸[cell] = { 값: example };
|
||||
return {
|
||||
...x,
|
||||
sheet,
|
||||
inputs: x.inputs.filter((i) => i.cell !== cell),
|
||||
variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */
|
||||
function incoming(st: State): { port: string; unit: string }[] {
|
||||
const out = new Map<string, string>();
|
||||
for (const l of st.lg.links) {
|
||||
if (l.to.box !== st.boxId || out.has(l.to.name)) continue;
|
||||
const src = st.lg.boxes.find((b) => b.id === l.from.box);
|
||||
const unit =
|
||||
src?.func && src.func.output.name === l.from.name
|
||||
? src.func.output.unit
|
||||
: (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ??
|
||||
src?.input?.variables.find((v) => v.name === l.from.name)?.unit ??
|
||||
src?.unit ??
|
||||
"-");
|
||||
out.set(l.to.name, unit || "-");
|
||||
}
|
||||
return [...out].map(([port, unit]) => ({ port, unit }));
|
||||
}
|
||||
|
||||
function problems(st: State): string[] {
|
||||
const f = funcOf(st);
|
||||
if (!f) return [];
|
||||
const out: string[] = [];
|
||||
for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) {
|
||||
if (typeof v === "object" && "오류" in v) {
|
||||
out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
|
||||
}
|
||||
}
|
||||
for (const p of incoming(st)) {
|
||||
if (!f.inputs.some((i) => i.port === p.port)) out.push(`${p.port}${tf("Unplaced")}`);
|
||||
}
|
||||
if (!f.output.cell) out.push(tf("OutNone"));
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
||||
|
||||
function applyMarks(st: State): void {
|
||||
const f = funcOf(st);
|
||||
if (!f) return;
|
||||
const marks = new Map<string, { kind: string; label: string }>();
|
||||
for (const i of f.inputs) marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` });
|
||||
for (const v of f.variables)
|
||||
if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` });
|
||||
if (f.output.cell)
|
||||
marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` });
|
||||
const locked = new Set(f.inputs.map((i) => i.cell));
|
||||
const list = problems(st);
|
||||
const bad = new Set(list.map((p) => p.split(" ")[0]));
|
||||
for (const c of st.parts.sheet.querySelectorAll<HTMLElement>(".aislo-cell")) {
|
||||
const addr = c.dataset.addr ?? "";
|
||||
for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a];
|
||||
const m = marks.get(addr);
|
||||
if (m) {
|
||||
c.dataset.cfKind = m.kind;
|
||||
c.dataset.cfName = m.label;
|
||||
}
|
||||
if (locked.has(addr)) c.dataset.cfLock = "🔒";
|
||||
if (bad.has(addr)) c.dataset.cfErr = "1";
|
||||
}
|
||||
st.parts.msg.textContent = list.length
|
||||
? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}`
|
||||
: "";
|
||||
st.parts.msg.hidden = !list.length;
|
||||
}
|
||||
|
||||
/* ------------------------------ 위 줄들 ------------------------------ */
|
||||
|
||||
function drawHead(st: State): void {
|
||||
const box = boxOf(st);
|
||||
const unit = el("input", {
|
||||
className: "ui-input m01-cfpanel-unit",
|
||||
attrs: { type: "text", "aria-label": tf("Unit") },
|
||||
});
|
||||
unit.value = box?.unit ?? "-";
|
||||
if (st.readOnly) unit.readOnly = true;
|
||||
unit.addEventListener("change", () => {
|
||||
const v = unit.value.trim() || "-";
|
||||
unit.value = v;
|
||||
patchBox(st, (b) => ({
|
||||
...b,
|
||||
unit: v,
|
||||
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func,
|
||||
}));
|
||||
});
|
||||
st.parts.head.replaceChildren(
|
||||
el("strong", { className: "m01-cfpanel-title", text: box?.name ?? "" }),
|
||||
el("span", { className: "m01-cfpanel-cell", text: `${tf("Cell")} ${st.cell}` }),
|
||||
el("label", {
|
||||
className: "m01-cfpanel-unitbox",
|
||||
children: [el("span", { className: "m01-cfpanel-note", text: tf("Unit") }), unit],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function drawGot(st: State): void {
|
||||
const f = funcOf(st);
|
||||
const items = incoming(st);
|
||||
const chips = items.map(({ port, unit }) => {
|
||||
const at = f?.inputs.find((i) => i.port === port)?.cell;
|
||||
const b = el("button", {
|
||||
className: `m01-cfpanel-chip is-in${at ? " is-placed" : ""}`,
|
||||
text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`,
|
||||
attrs: { type: "button", draggable: st.readOnly ? "false" : "true" },
|
||||
});
|
||||
b.addEventListener("click", () => placePort(st, port, st.cell));
|
||||
b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port));
|
||||
return b;
|
||||
});
|
||||
st.parts.got.replaceChildren(
|
||||
el("span", { className: "m01-cfpanel-label", text: tf("Got") }),
|
||||
...(chips.length ? chips : [el("span", { className: "m01-cfpanel-note", text: tf("NoGot") })]),
|
||||
...(chips.length && !st.readOnly
|
||||
? [el("span", { className: "m01-cfpanel-note", text: tf("GotHint") })]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
function formField(
|
||||
st: State,
|
||||
label: keyof typeof TEXT,
|
||||
key: "name" | "unit" | "min" | "max" | "choices" | "example",
|
||||
cls = "",
|
||||
): HTMLElement {
|
||||
const form = st.form!;
|
||||
const input = el("input", {
|
||||
className: `ui-input m01-cfpanel-field ${cls}`,
|
||||
attrs: { type: "text", "aria-label": tf(label) },
|
||||
});
|
||||
input.value = form[key];
|
||||
input.addEventListener("input", () => (form[key] = input.value));
|
||||
input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st));
|
||||
return el("label", {
|
||||
className: "m01-cfpanel-fieldbox",
|
||||
children: [el("span", { className: "m01-cfpanel-note", text: tf(label) }), input],
|
||||
});
|
||||
}
|
||||
|
||||
function drawVars(st: State): void {
|
||||
const f = funcOf(st);
|
||||
const row: HTMLElement[] = [el("span", { className: "m01-cfpanel-label", text: tf("Vars") })];
|
||||
for (const v of f?.variables ?? []) {
|
||||
const chip = el("span", {
|
||||
className: "m01-cfpanel-chip is-var",
|
||||
children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`],
|
||||
});
|
||||
if (!st.readOnly) {
|
||||
chip.append(
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
patchFunc(st, (x) => ({
|
||||
...x,
|
||||
variables: x.variables.filter((o) => o.name !== v.name),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`);
|
||||
}
|
||||
row.push(chip);
|
||||
}
|
||||
if (!f?.variables.length)
|
||||
row.push(el("span", { className: "m01-cfpanel-note", text: tf("NoVars") }));
|
||||
const parts: HTMLElement[] = [el("div", { className: "m01-cfpanel-row", children: row })];
|
||||
if (!st.readOnly) {
|
||||
const form = st.form;
|
||||
if (!form) {
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m01-cfpanel-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tf("MakeVar"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
st.form = {
|
||||
name: "",
|
||||
type: "number",
|
||||
unit: "",
|
||||
min: "",
|
||||
max: "",
|
||||
choices: "",
|
||||
example: "",
|
||||
why: "",
|
||||
};
|
||||
drawVars(st);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const type = el("select", {
|
||||
className: "ui-select m01-cfpanel-field",
|
||||
attrs: { "aria-label": tf("VarType") },
|
||||
});
|
||||
for (const [v, k] of [
|
||||
["number", "Number"],
|
||||
["choice", "Choice"],
|
||||
["text", "Text"],
|
||||
] as const) {
|
||||
type.append(el("option", { attrs: { value: v }, text: tf(k) }));
|
||||
}
|
||||
type.value = form.type;
|
||||
type.addEventListener("change", () => {
|
||||
form.type = type.value as CfVariable["type"];
|
||||
drawVars(st);
|
||||
});
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m01-cfpanel-row m01-cfpanel-form",
|
||||
children: [
|
||||
formField(st, "VarName", "name"),
|
||||
el("label", {
|
||||
className: "m01-cfpanel-fieldbox",
|
||||
children: [el("span", { className: "m01-cfpanel-note", text: tf("VarType") }), type],
|
||||
}),
|
||||
formField(st, "VarUnit", "unit"),
|
||||
...(form.type === "number"
|
||||
? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")]
|
||||
: []),
|
||||
...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []),
|
||||
formField(st, "VarExample", "example"),
|
||||
el("span", { className: "m01-cfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }),
|
||||
createButton({
|
||||
label: tf("Make"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => makeVariable(st),
|
||||
}),
|
||||
createButton({
|
||||
label: tf("Cancel"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((st.form = null), drawVars(st)),
|
||||
}),
|
||||
...(form.why
|
||||
? [el("span", { className: "m01-cfpanel-why", text: `⚠ ${form.why}` })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
st.parts.vars.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
function drawBar(st: State): void {
|
||||
const f = funcOf(st);
|
||||
if (st.readOnly) {
|
||||
st.parts.bar.replaceChildren(
|
||||
el("span", { className: "m01-cfpanel-note", text: tf("ReadOnly") }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone");
|
||||
st.parts.bar.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-cfpanel-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tf("OutBtn"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => setOutput(st, st.cell),
|
||||
}),
|
||||
el("span", { className: "m01-cfpanel-chip is-out", text: outNow }),
|
||||
el("span", { className: "m01-cfpanel-note", text: tf("OutOnly") }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-cfpanel-row",
|
||||
children: [
|
||||
el("span", { className: "m01-cfpanel-label", text: tf("Frames") }),
|
||||
...FRAMES.map(([key, formula]) =>
|
||||
createButton({
|
||||
label: tf(key),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => putFormula(st, formula),
|
||||
}),
|
||||
),
|
||||
el("span", { className: "m01-cfpanel-note", text: tf("FrameHint") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
|
||||
function setOutput(st: State, cell: string): void {
|
||||
const f = funcOf(st);
|
||||
if (!f) return;
|
||||
if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) {
|
||||
return void showToast(tf("Busy"), "error");
|
||||
}
|
||||
const row = cell.replace(/^[A-Z]+/, "");
|
||||
const label = f.sheet.칸[`A${row}`];
|
||||
const name = String(label?.값 ?? f.output.name ?? "").trim() || cell;
|
||||
patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } }));
|
||||
}
|
||||
|
||||
/* ------------------------------ 편집기 ------------------------------ */
|
||||
|
||||
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
|
||||
function putFormula(st: State, formula: string): void {
|
||||
const root = st.parts.sheet;
|
||||
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
|
||||
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
|
||||
if (!bar || !addr || st.readOnly) return;
|
||||
const at = st.cell;
|
||||
bar.focus();
|
||||
bar.value = formula;
|
||||
bar.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
bar.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
addr.value = at;
|
||||
addr.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */
|
||||
function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] {
|
||||
for (const cell of Object.values(sheet.칸)) delete cell.서식;
|
||||
return sheet;
|
||||
}
|
||||
|
||||
/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */
|
||||
function onBook(st: State, book: Workbook): void {
|
||||
const f = funcOf(st);
|
||||
if (!f) return;
|
||||
const sheet = plain(structuredClone(book.시트[0]));
|
||||
const hit = f.inputs.find(
|
||||
(i) => JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]),
|
||||
);
|
||||
if (hit) {
|
||||
showToast(tf("Locked"), "error");
|
||||
st.json = sheetJson(f);
|
||||
return st.handle?.setDoc(bookOf(f));
|
||||
}
|
||||
st.json = JSON.stringify(sheet);
|
||||
patchFunc(st, (x) => ({ ...x, sheet }));
|
||||
setTimeout(() => refreshSnap(st), 0);
|
||||
}
|
||||
|
||||
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
|
||||
function refreshSnap(st: State): void {
|
||||
if (!st.handle) return;
|
||||
st.snap = st.handle.recalc();
|
||||
st.redraw();
|
||||
}
|
||||
|
||||
async function mountSheet(st: State): Promise<void> {
|
||||
const { createSpreadsheet } = await loadSheet();
|
||||
const f = funcOf(st);
|
||||
if (!st.parts.sheet.isConnected || st.handle || !f) return;
|
||||
st.parts.sheet.replaceChildren();
|
||||
st.json = sheetJson(f);
|
||||
st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), {
|
||||
preview: true,
|
||||
tabs: false,
|
||||
readOnly: st.readOnly,
|
||||
onChange: (b) => onBook(st, b),
|
||||
onSelect: (s) => {
|
||||
st.cell = s.칸;
|
||||
drawHead(st);
|
||||
if (st.form) drawVars(st);
|
||||
},
|
||||
});
|
||||
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
|
||||
st.observer.observe(st.parts.sheet, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-addr"],
|
||||
});
|
||||
st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault());
|
||||
st.parts.sheet.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const port = e.dataTransfer?.getData("text/plain");
|
||||
const cell = (e.target as Element | null)?.closest<HTMLElement>(".aislo-cell")?.dataset.addr;
|
||||
if (port && cell) placePort(st, port, cell);
|
||||
});
|
||||
refreshSnap(st);
|
||||
}
|
||||
|
||||
/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
|
||||
export function renderFuncPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: Handlers,
|
||||
opts: { readOnly?: boolean } = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const readOnly = !!opts.readOnly;
|
||||
if (old && old.boxId === boxId && old.readOnly === readOnly) {
|
||||
old.lg = lg;
|
||||
old.on = on;
|
||||
const f = funcOf(old);
|
||||
if (old.handle && f && sheetJson(f) !== old.json) {
|
||||
old.json = sheetJson(f);
|
||||
old.handle.setDoc(bookOf(f));
|
||||
setTimeout(() => refreshSnap(old), 0);
|
||||
return;
|
||||
}
|
||||
return old.redraw();
|
||||
}
|
||||
if (old) {
|
||||
old.observer?.disconnect();
|
||||
old.handle?.destroy();
|
||||
}
|
||||
const parts: State["parts"] = {
|
||||
head: el("div", { className: "m01-cfpanel-head" }),
|
||||
got: el("div", { className: "m01-cfpanel-row" }),
|
||||
vars: el("div", { className: "m01-cfpanel-vars" }),
|
||||
bar: el("div", { className: "m01-cfpanel-bar" }),
|
||||
msg: el("p", { className: "m01-cfpanel-msg", attrs: { role: "status" } }),
|
||||
sheet: el("div", { className: "m01-cfpanel-sheet", text: tf("Loading") }),
|
||||
};
|
||||
const st: State = {
|
||||
boxId,
|
||||
lg,
|
||||
on,
|
||||
readOnly,
|
||||
handle: null,
|
||||
snap: {},
|
||||
json: "",
|
||||
cell: "A1",
|
||||
form: null,
|
||||
parts,
|
||||
observer: null,
|
||||
redraw: () => {
|
||||
drawHead(st);
|
||||
drawGot(st);
|
||||
drawVars(st);
|
||||
drawBar(st);
|
||||
applyMarks(st);
|
||||
},
|
||||
};
|
||||
states.set(host, st);
|
||||
host.classList.add("m01-cfpanel");
|
||||
host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet);
|
||||
parts.msg.hidden = true;
|
||||
st.redraw();
|
||||
void mountSheet(st);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
.m01-cfout__title {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-cfout__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-cfout__got {
|
||||
min-width: 220px;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-cfout__got dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
margin: var(--spacing-4) 0 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfout__got dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-cfout__got dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-cfout__num {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-cfout__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-cfout__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfout__input {
|
||||
box-sizing: border-box;
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfout__warn {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfout__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-cfout__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_OutputPanel.ts
|
||||
* 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸
|
||||
* 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름
|
||||
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { evalLogic, type CfEvalResult, type CfValue } from "./M02_CF_Eval";
|
||||
import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_CF_Model";
|
||||
import "./M02_CF_OutputPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["출력", "Output"],
|
||||
Name: ["이름", "Name"],
|
||||
Cost: ["비용 종류", "Cost kind"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Plain: ["값(비용 아님)", "Value (not a cost)"],
|
||||
Unit: ["단위", "Unit"],
|
||||
UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"],
|
||||
Got: ["받은 값", "Received value"],
|
||||
From: ["어디서", "From"],
|
||||
Value: ["값", "Value"],
|
||||
NoLink: [
|
||||
"아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요",
|
||||
"Nothing is linked yet — draw a line from an input or function box",
|
||||
],
|
||||
Table: ["표", "Table"],
|
||||
Calc: ["계산 중이 아니에요", "Not calculated"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
PlainHint: [
|
||||
"원가 합계에 안 넣고 값으로만 보여요",
|
||||
"Shown as a value, not added to the cost total",
|
||||
],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
const COSTS: [CfCost, TextKey][] = [
|
||||
["노무비", "Labor"],
|
||||
["재료비", "Material"],
|
||||
["경비", "Expense"],
|
||||
["값", "Plain"],
|
||||
];
|
||||
|
||||
/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */
|
||||
export function showValue(v: CfValue | undefined, money = false): string {
|
||||
if (v === undefined || v === null) return "—";
|
||||
if (Array.isArray(v)) return to("Table");
|
||||
if (typeof v === "number")
|
||||
return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
return String(v);
|
||||
}
|
||||
|
||||
const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)),
|
||||
});
|
||||
|
||||
/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */
|
||||
export function renderOutputPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
opts: { result?: CfEvalResult } = {},
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
if (!box || box.kind !== "output") {
|
||||
host.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const msg = el("p", { className: "m01-cfout__msg" });
|
||||
const emit = (make: () => CfLogic): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
msg.textContent = to("Cant");
|
||||
}
|
||||
};
|
||||
const cost = box.output?.cost ?? "노무비";
|
||||
|
||||
/* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */
|
||||
const link = lg.links.find((l) => l.to.box === box.id);
|
||||
const from = link && lg.boxes.find((b) => b.id === link.from.box);
|
||||
let result = opts.result;
|
||||
if (!result && link) {
|
||||
try {
|
||||
result = evalLogic(lg);
|
||||
} catch {
|
||||
result = undefined;
|
||||
}
|
||||
}
|
||||
let unit = "";
|
||||
if (from && link) {
|
||||
try {
|
||||
unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit;
|
||||
} catch {
|
||||
unit = from.unit;
|
||||
}
|
||||
}
|
||||
const got = el("div", { className: "m01-cfout__got" });
|
||||
if (link && from) {
|
||||
const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id];
|
||||
got.replaceChildren(
|
||||
el("strong", { text: to("Got") }),
|
||||
el("dl", {
|
||||
children: [
|
||||
el("dt", { text: to("From") }),
|
||||
el("dd", { text: `${from.name} · ${link.from.name}` }),
|
||||
el("dt", { text: to("Value") }),
|
||||
el("dd", {
|
||||
className: "m01-cfout__num",
|
||||
text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
got.replaceChildren(
|
||||
el("strong", { text: to("Got") }),
|
||||
el("p", { className: "m01-cfout__warn", text: to("NoLink") }),
|
||||
);
|
||||
}
|
||||
|
||||
/* 이름 · 비용 종류 · 단위 */
|
||||
const nameBox = el("input", {
|
||||
className: "m01-cfout__input",
|
||||
attrs: { type: "text", "aria-label": to("Name") },
|
||||
});
|
||||
nameBox.value = box.name;
|
||||
nameBox.addEventListener("change", () => {
|
||||
const next = nameBox.value.trim();
|
||||
if (!next || next === box.name) {
|
||||
nameBox.value = box.name;
|
||||
return;
|
||||
}
|
||||
emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next })));
|
||||
});
|
||||
const costField = createSelectField({
|
||||
label: to("Cost"),
|
||||
options: COSTS.map(([value, key]) => ({ value, text: to(key) })),
|
||||
value: cost,
|
||||
compact: true,
|
||||
onChange: (v) =>
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost },
|
||||
})),
|
||||
),
|
||||
});
|
||||
const unitBox = el("input", {
|
||||
className: "m01-cfout__input",
|
||||
attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") },
|
||||
});
|
||||
unitBox.value = box.output?.unit ?? box.unit;
|
||||
unitBox.addEventListener("change", () => {
|
||||
const next = unitBox.value.trim() || "-";
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
unit: next,
|
||||
output: { cost: b.output?.cost ?? "노무비", unit: next },
|
||||
})),
|
||||
);
|
||||
});
|
||||
const field = (label: string, control: HTMLElement): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m01-cfout__field",
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-cfout__title", text: `${to("Title")} · ${box.name}` }),
|
||||
el("div", {
|
||||
className: "m01-cfout__body",
|
||||
children: [
|
||||
got,
|
||||
el("div", {
|
||||
className: "m01-cfout__fields",
|
||||
children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...(cost === "값" ? [el("p", { className: "m01-cfout__muted", text: to("PlainHint") })] : []),
|
||||
msg,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Sample.ts
|
||||
* 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬)
|
||||
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비
|
||||
* ② 공구손료(비율) — 노무비 × 율 → 출력 재료비
|
||||
* ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값
|
||||
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
|
||||
* 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」).
|
||||
* 모양 = `M02_CF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port ·
|
||||
* output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { CfLogic } from "./M02_CF_Model";
|
||||
|
||||
const FILES = import.meta.glob<CfLogic>("./_sample/*.json", { eager: true, import: "default" });
|
||||
|
||||
/** 견본마다 한 줄 설명 — [한, 영] */
|
||||
const NOTE: Record<string, readonly [string, string]> = {
|
||||
masonry: [
|
||||
"변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비",
|
||||
"Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost",
|
||||
],
|
||||
rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material cost"],
|
||||
value: [
|
||||
"q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)",
|
||||
"q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)",
|
||||
],
|
||||
};
|
||||
|
||||
/** 보여 줄 차례 */
|
||||
const ORDER = ["masonry", "rate", "value"];
|
||||
|
||||
export interface CfSampleItem {
|
||||
/** 파일 이름(확장자 뺌) */
|
||||
id: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
/** 쉬운 말 한 줄 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, "");
|
||||
const byId = (): Map<string, CfLogic> =>
|
||||
new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg]));
|
||||
|
||||
/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */
|
||||
export function sampleList(): CfSampleItem[] {
|
||||
const all = byId();
|
||||
const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length);
|
||||
return [...all.entries()]
|
||||
.sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name))
|
||||
.map(([id, lg]) => ({
|
||||
id,
|
||||
name: lg.name,
|
||||
resultUnit: lg.resultUnit,
|
||||
note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */
|
||||
export function sampleLoad(id: string): CfLogic | null {
|
||||
const lg = byId().get(id);
|
||||
return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
.m01-cfsum__big {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-cfsum__big strong {
|
||||
color: var(--color-text-body);
|
||||
font-size: 28px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-cfsum__costs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-16);
|
||||
margin: 0 0 var(--spacing-12);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfsum__costs dt {
|
||||
display: inline;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-cfsum__costs dd {
|
||||
display: inline;
|
||||
margin: 0 0 0 var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-cfsum__block {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-cfsum__scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.m01-cfsum__table {
|
||||
width: 100%;
|
||||
min-width: 360px;
|
||||
margin-top: var(--spacing-4);
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfsum__table th,
|
||||
.m01-cfsum__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-cfsum__table th {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-cfsum__table td.m01-cfsum__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-cfsum__strip {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-cfsum__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-cfsum__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfsum__input {
|
||||
box-sizing: border-box;
|
||||
width: 140px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfsum__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-cfsum__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-cfsum__problems {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-cfsum__problems ul {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Summary.ts
|
||||
* 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄
|
||||
* · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈)
|
||||
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
|
||||
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { checkLogic, type CfEvalResult } from "./M02_CF_Eval";
|
||||
import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model";
|
||||
import { showValue } from "./M02_CF_OutputPanel";
|
||||
import "./M02_CF_Summary.css";
|
||||
|
||||
const TEXT = {
|
||||
Per: ["{per} 당", "per {per}"],
|
||||
Total: ["원가 합계", "Cost total"],
|
||||
Won: ["원", "KRW"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Lines: ["출력 상자별", "By output box"],
|
||||
HName: ["이름", "Name"],
|
||||
HCost: ["비용 종류", "Cost kind"],
|
||||
HValue: ["값", "Value"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
Plain: ["값(비용 아님)", "Values (not costs)"],
|
||||
Sample: ["예시값", "Sample values"],
|
||||
SampleHint: [
|
||||
"여기서 바꾸면 변수 상자의 예시값도 바뀌어요",
|
||||
"Changing here changes the variable box too",
|
||||
],
|
||||
Problems: ["고칠 것", "To fix"],
|
||||
NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"],
|
||||
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
Plain3: ["값", "Value"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const ts = (key: TextKey, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(
|
||||
/\{(\w+)\}/g,
|
||||
(m, k: string) => fill[k] ?? m,
|
||||
);
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
|
||||
|
||||
const costText = (c: string): string => (c === "값" ? ts("Plain3") : c);
|
||||
|
||||
/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */
|
||||
function emptyPlaces(lg: CfLogic): string[] {
|
||||
const outs = lg.boxes.filter((b) => b.kind === "output");
|
||||
if (!outs.length) return [ts("NoOutput")];
|
||||
return outs
|
||||
.filter((o) => !lg.links.some((l) => l.to.box === o.id))
|
||||
.map((o) => ts("NoLink", { box: o.name }));
|
||||
}
|
||||
|
||||
/** 변수 상자의 예시값 칸 하나 */
|
||||
function sampleField(
|
||||
v: CfVariable,
|
||||
put: (value: number | string) => void,
|
||||
msg: HTMLElement,
|
||||
): HTMLElement {
|
||||
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
|
||||
let control: HTMLElement;
|
||||
if (v.type === "choice" && v.choices?.length) {
|
||||
control = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: ts("Pick") },
|
||||
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
|
||||
],
|
||||
value: v.example === null ? "" : String(v.example),
|
||||
compact: true,
|
||||
ariaLabel: v.name,
|
||||
onChange: (s) => {
|
||||
const hit = v.choices?.find((c) => String(c) === s);
|
||||
if (hit !== undefined) put(hit);
|
||||
},
|
||||
}).root;
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m01-cfsum__input",
|
||||
attrs: {
|
||||
type: "text",
|
||||
inputmode: v.type === "number" ? "decimal" : "text",
|
||||
"aria-label": v.name,
|
||||
},
|
||||
});
|
||||
if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) });
|
||||
box.value = v.example === null ? "" : String(v.example);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
if (raw === "") return;
|
||||
if (v.type !== "number") return put(raw);
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) msg.textContent = ts("Bad");
|
||||
else put(n);
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
return el("label", { className: "m01-cfsum__field", children: [label, control] });
|
||||
}
|
||||
|
||||
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */
|
||||
export function renderSummary(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on?: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
|
||||
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
|
||||
const perText = !perUnit
|
||||
? ts("Total")
|
||||
: ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
|
||||
const { cost } = result;
|
||||
const big = el("p", {
|
||||
className: "m01-cfsum__big",
|
||||
children: [
|
||||
el("span", { text: money ? perText : ts("Total") }),
|
||||
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
|
||||
],
|
||||
});
|
||||
const costs = el("dl", {
|
||||
className: "m01-cfsum__costs",
|
||||
children: COSTS.flatMap((c) => [
|
||||
el("dt", { text: ts(COST_KEY[c]) }),
|
||||
el("dd", { text: formatMoney(cost[c]) }),
|
||||
]),
|
||||
});
|
||||
|
||||
/* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */
|
||||
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
|
||||
const costed = outs.filter((b) => b.output?.cost !== "값");
|
||||
const plain = outs.filter((b) => b.output?.cost === "값");
|
||||
const line = (b: CfBox, isCost: boolean): HTMLElement =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: b.name }),
|
||||
...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []),
|
||||
el("td", {
|
||||
className: "m01-cfsum__num",
|
||||
text: showValue(result.outputs[b.id], isCost),
|
||||
}),
|
||||
el("td", { text: b.output?.unit ?? b.unit }),
|
||||
],
|
||||
});
|
||||
const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-cfsum__block",
|
||||
children: [
|
||||
el("strong", { text: title }),
|
||||
el("div", {
|
||||
className: "m01-cfsum__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-cfsum__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
el("th", { text: ts("HName") }),
|
||||
...(isCost ? [el("th", { text: ts("HCost") })] : []),
|
||||
el("th", { text: ts("HValue") }),
|
||||
el("th", { text: ts("HUnit") }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: list.map((b) => line(b, isCost)) }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */
|
||||
let checked: string[] = [];
|
||||
try {
|
||||
checked = checkLogic(lg);
|
||||
} catch {
|
||||
checked = [];
|
||||
}
|
||||
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
|
||||
|
||||
/* 예시값 — 변수 상자의 변수마다 */
|
||||
const msg = el("p", { className: "m01-cfsum__msg" });
|
||||
const fields = lg.boxes
|
||||
.filter((b) => b.kind === "input" && b.input?.mode === "variable")
|
||||
.flatMap((b) =>
|
||||
(b.input?.variables ?? []).map((v, i) =>
|
||||
sampleField(
|
||||
v,
|
||||
(value) => {
|
||||
msg.textContent = "";
|
||||
if (!on) return;
|
||||
try {
|
||||
on.change({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((x) =>
|
||||
x.id === b.id && x.input
|
||||
? {
|
||||
...x,
|
||||
input: {
|
||||
...x.input,
|
||||
variables: x.input.variables.map((y, j) =>
|
||||
j === i ? { ...y, example: value } : y,
|
||||
),
|
||||
},
|
||||
}
|
||||
: x,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
msg.textContent = ts("Cant");
|
||||
}
|
||||
},
|
||||
msg,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const plainValues = Object.entries(cost.값들);
|
||||
host.replaceChildren(
|
||||
big,
|
||||
costs,
|
||||
...(costed.length ? [table(costed, true, ts("Lines"))] : []),
|
||||
...(plain.length
|
||||
? [table(plain, false, ts("Plain"))]
|
||||
: plainValues.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-cfsum__block",
|
||||
children: [
|
||||
el("strong", { text: ts("Plain") }),
|
||||
...plainValues.map(([k, v]) =>
|
||||
el("p", { className: "m01-cfsum__muted", text: `${k} · ${showValue(v)}` }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(fields.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-cfsum__strip",
|
||||
children: [
|
||||
el("strong", { text: ts("Sample") }),
|
||||
el("div", { className: "m01-cfsum__fields", children: fields }),
|
||||
el("p", { className: "m01-cfsum__muted", text: ts("SampleHint") }),
|
||||
msg,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(problems.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-cfsum__problems",
|
||||
children: [
|
||||
el("strong", { text: ts("Problems") }),
|
||||
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
{
|
||||
"key": "SAMPLE-masonry",
|
||||
"name": "견본 · 돌쌓기 메쌓기(인력)",
|
||||
"resultUnit": "원/㎡",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-var",
|
||||
"kind": "input",
|
||||
"name": "쌓기 조건",
|
||||
"color": "#3b82f6",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 40,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "뒷길이",
|
||||
"type": "choice",
|
||||
"unit": "㎝",
|
||||
"example": 30,
|
||||
"choices": [25, 30, 35, 45, 55, 60, 75]
|
||||
},
|
||||
{
|
||||
"name": "돌",
|
||||
"type": "choice",
|
||||
"unit": "-",
|
||||
"example": "깬돌",
|
||||
"choices": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]
|
||||
},
|
||||
{
|
||||
"name": "쌓기",
|
||||
"type": "choice",
|
||||
"unit": "-",
|
||||
"example": "골쌓기",
|
||||
"choices": ["골쌓기", "켜쌓기"]
|
||||
},
|
||||
{
|
||||
"name": "높이",
|
||||
"type": "number",
|
||||
"unit": "m",
|
||||
"example": 4.5
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "in-poom",
|
||||
"kind": "input",
|
||||
"name": "메쌓기 품표",
|
||||
"color": "#3b82f6",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 300,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "소요량",
|
||||
"elements": [],
|
||||
"table": {
|
||||
"key": "QF000421"
|
||||
},
|
||||
"variables": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "in-rate",
|
||||
"kind": "input",
|
||||
"name": "높이 증가율표",
|
||||
"color": "#3b82f6",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 460,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "계수",
|
||||
"elements": [],
|
||||
"table": {
|
||||
"key": "QF000422"
|
||||
},
|
||||
"variables": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "in-labor",
|
||||
"kind": "input",
|
||||
"name": "인력",
|
||||
"color": "#3b82f6",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 620,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "인력",
|
||||
"variables": [],
|
||||
"elements": [
|
||||
{
|
||||
"ref": "LB000033",
|
||||
"name": "석공",
|
||||
"spec": "",
|
||||
"unit": "원/일"
|
||||
},
|
||||
{
|
||||
"ref": "LB000002",
|
||||
"name": "보통인부",
|
||||
"spec": "",
|
||||
"unit": "원/일"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-rate",
|
||||
"kind": "function",
|
||||
"name": "증가율 찾기",
|
||||
"color": "#f59e0b",
|
||||
"unit": "%",
|
||||
"x": 340,
|
||||
"y": 420,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-rate",
|
||||
"이름": "증가율 찾기",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "높이"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · m"
|
||||
},
|
||||
"A3": {
|
||||
"값": "표에서 찾은 증가율"
|
||||
},
|
||||
"B3": {
|
||||
"식": "SUMIFS($G$2:$G$100,$E$2:$E$100,\"<\"&B2,$F$2:$F$100,\">=\"&B2)"
|
||||
},
|
||||
"C3": {
|
||||
"값": "높이가 「아래 < 높이 ≤ 위」 인 줄"
|
||||
},
|
||||
"A4": {
|
||||
"값": "증가율"
|
||||
},
|
||||
"B4": {
|
||||
"식": "IF(B2<=3,0,B3)"
|
||||
},
|
||||
"C4": {
|
||||
"값": "3m 까지는 할증 없음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "높이",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "증가율표",
|
||||
"cell": "E1"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B4",
|
||||
"name": "증가율",
|
||||
"unit": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-poom0",
|
||||
"kind": "function",
|
||||
"name": "석공 품",
|
||||
"color": "#f59e0b",
|
||||
"unit": "인/㎡",
|
||||
"x": 640,
|
||||
"y": 80,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-poom0",
|
||||
"이름": "석공 품",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "뒷길이"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · ㎝"
|
||||
},
|
||||
"A3": {
|
||||
"값": "돌"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값"
|
||||
},
|
||||
"A4": {
|
||||
"값": "쌓기"
|
||||
},
|
||||
"C4": {
|
||||
"값": "받는 값"
|
||||
},
|
||||
"A5": {
|
||||
"값": "증가율"
|
||||
},
|
||||
"C5": {
|
||||
"값": "받는 값 · %"
|
||||
},
|
||||
"A6": {
|
||||
"값": "표에서 찾은 품"
|
||||
},
|
||||
"B6": {
|
||||
"식": "SUMIFS($H$2:$H$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)"
|
||||
},
|
||||
"C6": {
|
||||
"값": "세 조건이 맞는 줄의 품"
|
||||
},
|
||||
"A7": {
|
||||
"값": "석공 품"
|
||||
},
|
||||
"B7": {
|
||||
"식": "B6*(1+B5/100)"
|
||||
},
|
||||
"C7": {
|
||||
"값": "높이 할증을 얹음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "뒷길이",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "돌",
|
||||
"cell": "B3"
|
||||
},
|
||||
{
|
||||
"port": "쌓기",
|
||||
"cell": "B4"
|
||||
},
|
||||
{
|
||||
"port": "증가율",
|
||||
"cell": "B5"
|
||||
},
|
||||
{
|
||||
"port": "품표",
|
||||
"cell": "E1"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B7",
|
||||
"name": "석공 품",
|
||||
"unit": "인/㎡"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-poom1",
|
||||
"kind": "function",
|
||||
"name": "보통인부 품",
|
||||
"color": "#f59e0b",
|
||||
"unit": "인/㎡",
|
||||
"x": 640,
|
||||
"y": 300,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-poom1",
|
||||
"이름": "보통인부 품",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "뒷길이"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · ㎝"
|
||||
},
|
||||
"A3": {
|
||||
"값": "돌"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값"
|
||||
},
|
||||
"A4": {
|
||||
"값": "쌓기"
|
||||
},
|
||||
"C4": {
|
||||
"값": "받는 값"
|
||||
},
|
||||
"A5": {
|
||||
"값": "증가율"
|
||||
},
|
||||
"C5": {
|
||||
"값": "받는 값 · %"
|
||||
},
|
||||
"A6": {
|
||||
"값": "표에서 찾은 품"
|
||||
},
|
||||
"B6": {
|
||||
"식": "SUMIFS($I$2:$I$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)"
|
||||
},
|
||||
"C6": {
|
||||
"값": "세 조건이 맞는 줄의 품"
|
||||
},
|
||||
"A7": {
|
||||
"값": "보통인부 품"
|
||||
},
|
||||
"B7": {
|
||||
"식": "B6*(1+B5/100)"
|
||||
},
|
||||
"C7": {
|
||||
"값": "높이 할증을 얹음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "뒷길이",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "돌",
|
||||
"cell": "B3"
|
||||
},
|
||||
{
|
||||
"port": "쌓기",
|
||||
"cell": "B4"
|
||||
},
|
||||
{
|
||||
"port": "증가율",
|
||||
"cell": "B5"
|
||||
},
|
||||
{
|
||||
"port": "품표",
|
||||
"cell": "E1"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B7",
|
||||
"name": "보통인부 품",
|
||||
"unit": "인/㎡"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-pay0",
|
||||
"kind": "function",
|
||||
"name": "석공 노무비",
|
||||
"color": "#f59e0b",
|
||||
"unit": "원/㎡",
|
||||
"x": 940,
|
||||
"y": 80,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-pay0",
|
||||
"이름": "석공 노무비",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "석공 품"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 인/㎡"
|
||||
},
|
||||
"A3": {
|
||||
"값": "석공 노임"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 원/인"
|
||||
},
|
||||
"A4": {
|
||||
"값": "석공 노무비"
|
||||
},
|
||||
"B4": {
|
||||
"식": "B2*B3"
|
||||
},
|
||||
"C4": {
|
||||
"값": "품 × 노임"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "석공 품",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "석공 노임",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B4",
|
||||
"name": "석공 노무비",
|
||||
"unit": "원/㎡"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-pay1",
|
||||
"kind": "function",
|
||||
"name": "보통인부 노무비",
|
||||
"color": "#f59e0b",
|
||||
"unit": "원/㎡",
|
||||
"x": 940,
|
||||
"y": 300,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-pay1",
|
||||
"이름": "보통인부 노무비",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "보통인부 품"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값 · 인/㎡"
|
||||
},
|
||||
"A3": {
|
||||
"값": "보통인부 노임"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값 · 원/인"
|
||||
},
|
||||
"A4": {
|
||||
"값": "보통인부 노무비"
|
||||
},
|
||||
"B4": {
|
||||
"식": "B2*B3"
|
||||
},
|
||||
"C4": {
|
||||
"값": "품 × 노임"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "보통인부 품",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "보통인부 노임",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B4",
|
||||
"name": "보통인부 노무비",
|
||||
"unit": "원/㎡"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-sum",
|
||||
"kind": "function",
|
||||
"name": "노무비 합",
|
||||
"color": "#f59e0b",
|
||||
"unit": "원/㎡",
|
||||
"x": 1240,
|
||||
"y": 190,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-sum",
|
||||
"이름": "노무비 합",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "석공 노무비"
|
||||
},
|
||||
"C2": {
|
||||
"값": "받는 값"
|
||||
},
|
||||
"A3": {
|
||||
"값": "보통인부 노무비"
|
||||
},
|
||||
"C3": {
|
||||
"값": "받는 값"
|
||||
},
|
||||
"A4": {
|
||||
"값": "노무비"
|
||||
},
|
||||
"B4": {
|
||||
"식": "B2+B3"
|
||||
},
|
||||
"C4": {
|
||||
"값": "둘을 더함"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "석공 노무비",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "보통인부 노무비",
|
||||
"cell": "B3"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B4",
|
||||
"name": "노무비",
|
||||
"unit": "원/㎡"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "out-pay",
|
||||
"kind": "output",
|
||||
"name": "노무비",
|
||||
"color": "#10b981",
|
||||
"unit": "원/㎡",
|
||||
"x": 1520,
|
||||
"y": 190,
|
||||
"output": {
|
||||
"cost": "노무비",
|
||||
"unit": "원/㎡"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "높이"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-rate",
|
||||
"name": "높이"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": {
|
||||
"box": "in-rate",
|
||||
"name": "표"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-rate",
|
||||
"name": "증가율표"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "뒷길이"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom0",
|
||||
"name": "뒷길이"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "돌"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom0",
|
||||
"name": "돌"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l5",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "쌓기"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom0",
|
||||
"name": "쌓기"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l6",
|
||||
"from": {
|
||||
"box": "fn-rate",
|
||||
"name": "증가율"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom0",
|
||||
"name": "증가율"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l7",
|
||||
"from": {
|
||||
"box": "in-poom",
|
||||
"name": "표"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom0",
|
||||
"name": "품표"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l8",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "뒷길이"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom1",
|
||||
"name": "뒷길이"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l9",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "돌"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom1",
|
||||
"name": "돌"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l10",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "쌓기"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom1",
|
||||
"name": "쌓기"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l11",
|
||||
"from": {
|
||||
"box": "fn-rate",
|
||||
"name": "증가율"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom1",
|
||||
"name": "증가율"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l12",
|
||||
"from": {
|
||||
"box": "in-poom",
|
||||
"name": "표"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-poom1",
|
||||
"name": "품표"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l13",
|
||||
"from": {
|
||||
"box": "fn-poom0",
|
||||
"name": "석공 품"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-pay0",
|
||||
"name": "석공 품"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l14",
|
||||
"from": {
|
||||
"box": "in-labor",
|
||||
"name": "석공"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-pay0",
|
||||
"name": "석공 노임"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l15",
|
||||
"from": {
|
||||
"box": "fn-poom1",
|
||||
"name": "보통인부 품"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-pay1",
|
||||
"name": "보통인부 품"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l16",
|
||||
"from": {
|
||||
"box": "in-labor",
|
||||
"name": "보통인부"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-pay1",
|
||||
"name": "보통인부 노임"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l17",
|
||||
"from": {
|
||||
"box": "fn-pay0",
|
||||
"name": "석공 노무비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-sum",
|
||||
"name": "석공 노무비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l18",
|
||||
"from": {
|
||||
"box": "fn-pay1",
|
||||
"name": "보통인부 노무비"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-sum",
|
||||
"name": "보통인부 노무비"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l19",
|
||||
"from": {
|
||||
"box": "fn-sum",
|
||||
"name": "노무비"
|
||||
},
|
||||
"to": {
|
||||
"box": "out-pay",
|
||||
"name": "값"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userElements": []
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"key": "SAMPLE-rate",
|
||||
"name": "견본 · 공구손료(비율)",
|
||||
"resultUnit": "원",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-var",
|
||||
"kind": "input",
|
||||
"name": "바탕 값",
|
||||
"color": "#3b82f6",
|
||||
"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": "#f59e0b",
|
||||
"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": "#10b981",
|
||||
"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": []
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"key": "SAMPLE-value",
|
||||
"name": "견본 · 덤프트럭 시간당 작업량(값)",
|
||||
"resultUnit": "㎥/시간",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "in-var",
|
||||
"kind": "input",
|
||||
"name": "덤프트럭 조건",
|
||||
"color": "#3b82f6",
|
||||
"unit": "-",
|
||||
"x": 40,
|
||||
"y": 80,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{
|
||||
"name": "q",
|
||||
"type": "number",
|
||||
"unit": "㎥",
|
||||
"example": 6
|
||||
},
|
||||
{
|
||||
"name": "f",
|
||||
"type": "number",
|
||||
"unit": "-",
|
||||
"example": 1.2
|
||||
},
|
||||
{
|
||||
"name": "E",
|
||||
"type": "number",
|
||||
"unit": "-",
|
||||
"example": 0.9
|
||||
},
|
||||
{
|
||||
"name": "Cm",
|
||||
"type": "number",
|
||||
"unit": "분",
|
||||
"example": 25
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fn-q",
|
||||
"kind": "function",
|
||||
"name": "시간당 작업량",
|
||||
"color": "#f59e0b",
|
||||
"unit": "㎥/시간",
|
||||
"x": 340,
|
||||
"y": 80,
|
||||
"func": {
|
||||
"sheet": {
|
||||
"id": "cf-q",
|
||||
"이름": "시간당 작업량",
|
||||
"칸": {
|
||||
"A1": {
|
||||
"값": "이름"
|
||||
},
|
||||
"B1": {
|
||||
"값": "값"
|
||||
},
|
||||
"C1": {
|
||||
"값": "설명"
|
||||
},
|
||||
"A2": {
|
||||
"값": "q"
|
||||
},
|
||||
"C2": {
|
||||
"값": "적재량 · ㎥"
|
||||
},
|
||||
"A3": {
|
||||
"값": "f"
|
||||
},
|
||||
"C3": {
|
||||
"값": "토량환산계수"
|
||||
},
|
||||
"A4": {
|
||||
"값": "E"
|
||||
},
|
||||
"C4": {
|
||||
"값": "작업효율"
|
||||
},
|
||||
"A5": {
|
||||
"값": "Cm"
|
||||
},
|
||||
"C5": {
|
||||
"값": "1회 사이클 시간 · 분"
|
||||
},
|
||||
"A6": {
|
||||
"값": "시간당 작업량"
|
||||
},
|
||||
"B6": {
|
||||
"식": "ROUND(60*B2*B3*B4/B5,3)"
|
||||
},
|
||||
"C6": {
|
||||
"값": "60 × q × f × E ÷ Cm · 소수 셋째 자리"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"port": "q",
|
||||
"cell": "B2"
|
||||
},
|
||||
{
|
||||
"port": "f",
|
||||
"cell": "B3"
|
||||
},
|
||||
{
|
||||
"port": "E",
|
||||
"cell": "B4"
|
||||
},
|
||||
{
|
||||
"port": "Cm",
|
||||
"cell": "B5"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"output": {
|
||||
"cell": "B6",
|
||||
"name": "시간당 작업량",
|
||||
"unit": "㎥/시간"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "out-q",
|
||||
"kind": "output",
|
||||
"name": "시간당 작업량",
|
||||
"color": "#10b981",
|
||||
"unit": "㎥/시간",
|
||||
"x": 640,
|
||||
"y": 80,
|
||||
"output": {
|
||||
"cost": "값",
|
||||
"unit": "㎥/시간"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "q"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-q",
|
||||
"name": "q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "f"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-q",
|
||||
"name": "f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "E"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-q",
|
||||
"name": "E"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"from": {
|
||||
"box": "in-var",
|
||||
"name": "Cm"
|
||||
},
|
||||
"to": {
|
||||
"box": "fn-q",
|
||||
"name": "Cm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "l5",
|
||||
"from": {
|
||||
"box": "fn-q",
|
||||
"name": "시간당 작업량"
|
||||
},
|
||||
"to": {
|
||||
"box": "out-q",
|
||||
"name": "값"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userElements": []
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`.
|
||||
|
||||
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
|
||||
(Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만
|
||||
· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100
|
||||
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow"
|
||||
SAMPLE_TS = FOLDER / "M02_CF_Sample.ts"
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
|
||||
|
||||
HOOKS = r"""
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
export async function load(url, ctx, next) {
|
||||
if (!url.endsWith(".ts")) return next(url, ctx);
|
||||
let src = readFileSync(fileURLToPath(url), "utf8");
|
||||
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples");
|
||||
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => {
|
||||
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
|
||||
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
|
||||
});
|
||||
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str) -> object:
|
||||
files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()}
|
||||
script = (
|
||||
"import { register } from 'node:module';\n"
|
||||
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
||||
"globalThis.__stub = { currentLanguageIndex: 0 };\n"
|
||||
f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n"
|
||||
f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n"
|
||||
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--no-warnings", "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr[-2000:]
|
||||
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"]
|
||||
return (
|
||||
[e["name"] for e in inp.get("elements", [])]
|
||||
+ (["표"] if inp.get("table") else [])
|
||||
+ [v["name"] for v in inp.get("variables", [])]
|
||||
)
|
||||
|
||||
|
||||
def _ports_in(box: dict) -> list[str]:
|
||||
if box["kind"] == "function":
|
||||
return [i["port"] for i in box["func"]["inputs"]]
|
||||
return ["값"] if box["kind"] == "output" else []
|
||||
|
||||
|
||||
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
|
||||
done: list[str] = []
|
||||
while len(done) < len(boxes):
|
||||
ready = [i for i in boxes if i not in done and need[i] <= set(done)]
|
||||
assert ready, "선이 돌고 돎"
|
||||
done += ready
|
||||
return [boxes[i] for i in done]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["masonry", "rate", "value"])
|
||||
def test_견본_모양(name: str) -> None:
|
||||
lg = SAMPLES[name]
|
||||
assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == []
|
||||
boxes = {b["id"]: b for b in lg["boxes"]}
|
||||
assert len(boxes) == len(lg["boxes"])
|
||||
sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"]
|
||||
assert len(set(sheets)) == len(sheets)
|
||||
for b in lg["boxes"]:
|
||||
assert b["kind"] in ("input", "function", "output")
|
||||
assert (
|
||||
b["color"].startswith("#")
|
||||
and b["unit"]
|
||||
and 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 len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"])
|
||||
if b["kind"] == "output":
|
||||
assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값")
|
||||
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
|
||||
# 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오
|
||||
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"]
|
||||
_order(lg)
|
||||
|
||||
|
||||
def test_견본_목록_열기() -> None:
|
||||
got = _node(
|
||||
"out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });"
|
||||
)
|
||||
assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"]
|
||||
assert all(x["note"] for x in got["list"])
|
||||
assert got["one"]["key"] is None and got["one"]["boxes"] == SAMPLES["masonry"]["boxes"]
|
||||
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
|
||||
|
||||
|
||||
def _table_rows(table: dict) -> list[list]:
|
||||
"""품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)"""
|
||||
conds, lines = table.get("조건") or {}, table.get("줄") or []
|
||||
names = [*conds, *(table.get("값칸") or {})]
|
||||
split = {
|
||||
n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines)
|
||||
}
|
||||
head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))]
|
||||
rows = []
|
||||
for r in lines:
|
||||
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)
|
||||
rows.append(line)
|
||||
return [head, *rows]
|
||||
|
||||
|
||||
def _plain(v):
|
||||
return float(v) if isinstance(v, Decimal) else v
|
||||
|
||||
|
||||
def _run(lg: dict, master) -> 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
|
||||
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
|
||||
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", [])
|
||||
}
|
||||
if inp.get("table"):
|
||||
got["표"] = _table_rows(tables[inp["table"]["key"]])
|
||||
ports[b["id"]] = got
|
||||
elif b["kind"] == "function":
|
||||
fn = b["func"]
|
||||
sheet = json.loads(json.dumps(fn["sheet"]))
|
||||
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 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["수"])}
|
||||
else:
|
||||
ports[b["id"]] = {"값": into["값"]}
|
||||
return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"}
|
||||
|
||||
|
||||
def test_견본1_원가_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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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
|
||||
Reference in New Issue
Block a user