Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3

This commit is contained in:
2026-10-01 19:38:51 +09:00
31 changed files with 9340 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
_data/
+158
View File
@@ -0,0 +1,158 @@
/* =============================================================================
* M02_DF_Api.ts
* 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 받아
* 함수 상자 시트를 같은 엔진으로 셈하고 받는 칸에 값만 되돌려 넣음) · 지우기 · 입력 값 한 번에 받기 ·
* 사용자 요소. 모양 = sub7 Model(CfLogic · CfGroup · CfUserElement) · 아래는 Api 몫만.
* 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { CfGroup, CfLogic, CfUserElement } from "./M02_DF_Model";
/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */
export type CfField = "노무비" | "재료비" | "경비" | "계";
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `UE-…` 는 다이어그램 안 · `_data`)에서
* · 인력 · 재료 ref = 키 → 그 줄 단가(기존 계산과 같은 셈) · 재료 ref = 검색어 묶음 JSON 글도 됨(unit 을 주면
* 기존 로직 재료 줄과 같은 단가)
* · 유가전력 · 환율 = 그 줄 값 · 요율 = 값 묶음의 field(기본 rate_percent · %)
* · 기계 ref = EQ 키 + field(값 칸 — 가격 · 손료계수 …) 또는 `로직(GC…, 기계='…', 지역=지역)` + inputs → 한 시간 값
* · 소요량 · 계수 table = {key, row?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」) */
export interface CfQuery {
id: string;
group: CfGroup;
ref?: string;
table?: { key: string; row?: number };
unit?: string;
field?: string;
inputs?: Record<string, unknown>;
}
/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
export interface CfAnswer {
id: string;
ok: boolean;
value?: number | string | null;
rows?: (string | number | null)[][];
name?: string;
unit?: string;
reason?: string;
}
/** 서버 계산 결과 — 저장 답 */
export interface CfComputed {
/** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */
ports: Record<string, Record<string, unknown>>;
/** 출력 상자 id → 값 */
outputs: Record<string, number | string | boolean | null>;
cost: {
노무비: number;
재료비: number;
경비: number;
값들: Record<string, number | string | boolean | null>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */
export interface CfListItem {
key: string;
name: string;
resultUnit: string;
saved: string;
sample: boolean;
}
/** 저장 답 — `logic` 은 받는 칸에 서버 값이 들어간 다이어그램 */
export interface CfSaved {
key: string;
logic: CfLogic;
values: CfComputed;
}
/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */
export class CfError extends Error {
constructor(
public status: number,
public detail: unknown,
) {
super(typeof detail === "string" ? detail : JSON.stringify(detail));
}
}
async function send<T>(path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m02/df${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data;
}
/** 내 다이어그램(저장 차례) + 견본 */
export async function cfList(): Promise<CfListItem[]> {
return (await send<{ logics: CfListItem[] }>("/list")).logics;
}
export async function cfGet(key: string): Promise<CfLogic> {
return (await send<{ logic: CfLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
}
/** 저장 — 서버가 입력 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) ·
* 견본을 저장하면 새 번호 */
export async function cfSave(logic: CfLogic): Promise<CfSaved> {
return send<CfSaved>("/save", { logic });
}
export async function cfDelete(key: string): Promise<void> {
await send<{ key: string }>("/delete", { key });
}
/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 ·
* userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */
export async function cfValues(
items: CfQuery[],
userElements: CfUserElement[] = [],
): Promise<CfAnswer[]> {
return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values;
}
/** 사용자 요소 목록 */
export async function cfUserElements(): Promise<CfUserElement[]> {
return (await send<{ elements: CfUserElement[] }>("/user-elements")).elements;
}
/** 사용자 요소 만들기(공용 목록 `_data`) — id(`UE-0001`)는 서버가 줌 · 마스터에 안 씀 */
export async function cfUserElementAdd(element: Omit<CfUserElement, "id">): Promise<CfUserElement> {
return (await send<{ element: CfUserElement }>("/user-elements", { op: "add", element })).element;
}
export async function cfUserElementDelete(id: string): Promise<void> {
await send<{ id: string }>("/user-elements", { op: "delete", id });
}
const PLAIN: Record<number, string> = {
0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요",
400: "넣은 값의 모양이 맞지 않음",
403: "견본은 바꾸거나 지울 수 없음 — 저장하면 새 다이어그램으로 남음",
404: "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
503: "엑셀 계산을 서버에서 못 함 — 잠시 뒤 다시 해 주세요",
};
/** 실패 → 쉬운 말(키 · 날 글이 섞인 서버 글은 뭉뚱그림) */
export function cfPlain(e: unknown): string {
if (!(e instanceof CfError)) return e instanceof Error ? e.message : PLAIN[0];
const text = typeof e.detail === "string" ? e.detail : "";
if (e.status === 400 && text && !/[A-Z]{2}\d{6}|[{}]/.test(text)) return text;
return PLAIN[e.status] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요";
}
@@ -0,0 +1,276 @@
/* 단가산출 다이어그램 — 자유 배치 */
.m02-dfdg {
--cfdg-in: var(--color-accent);
--cfdg-out: #c2610c;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
font-size: var(--text-body-sm);
}
:root[data-theme="dark"] .m02-dfdg {
--cfdg-out: #e5a866;
--cfdg-lift: #fff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .m02-dfdg {
--cfdg-out: #e5a866;
--cfdg-lift: #fff;
}
}
.m02-dfdg__bar,
.m02-dfdg__tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfdg__tools {
min-height: 1.75rem;
}
.m02-dfdg__title {
font-size: var(--text-body);
}
.m02-dfdg__gap {
flex: 1;
}
.m02-dfdg__zoom {
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.m02-dfdg__note {
color: var(--color-danger);
}
.m02-dfdg__hint {
margin: 0;
color: var(--color-text-muted);
}
/* 판 — 화면 위쪽 큰 영역 */
.m02-dfdg__view {
position: relative;
height: max(360px, 55vh);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-color: var(--color-canvas);
background-image: radial-gradient(
color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px,
transparent 1px
);
background-size: 20px 20px;
cursor: grab;
touch-action: none;
}
.m02-dfdg__world {
position: absolute;
inset: 0 auto auto 0;
width: 1px;
height: 1px;
transform-origin: 0 0;
}
.m02-dfdg__lines {
position: absolute;
inset: 0 auto auto 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */
.m02-dfdg__box {
position: absolute;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 11rem;
max-width: 16rem;
padding: 0 0 var(--spacing-8);
border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border));
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
cursor: pointer;
}
.m02-dfdg__box:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.m02-dfdg__box.is-selected {
border: 2px solid var(--cf-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent);
}
.m02-dfdg__head {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 0 var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface));
cursor: move;
}
.m02-dfdg__kind {
grid-column: 1;
/* 어두운 화면은 종류 색을 밝게 섞음 */
color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color)));
font-size: var(--text-caption);
font-weight: 600;
}
.m02-dfdg__unit {
grid-column: 2;
grid-row: 1;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m02-dfdg__name {
grid-column: 1 / -1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-dfdg__pills {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
padding: 0 var(--spacing-8);
}
.m02-dfdg__ins,
.m02-dfdg__outs {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-dfdg__outs {
align-items: flex-end;
}
/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */
.m02-dfdg__pill {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-4);
max-width: 9rem;
padding: 0 var(--spacing-8);
border: 1px solid var(--cfdg-in);
border-radius: 999px;
background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface));
color: var(--cfdg-in);
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
white-space: nowrap;
}
.m02-dfdg__pill.is-out {
border-color: var(--cfdg-out);
background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface));
color: var(--cfdg-out);
cursor: grab;
touch-action: none;
}
.m02-dfdg__pill.is-empty {
border-style: dashed;
border-color: var(--color-text-muted);
background: transparent;
color: var(--color-text-muted);
}
.m02-dfdg__pill.is-chosen {
box-shadow: 0 0 0 2px currentColor;
}
.m02-dfdg.is-linking .m02-dfdg__pill.is-target {
box-shadow: 0 0 0 2px var(--cfdg-in);
}
.m02-dfdg__pill-name {
overflow: hidden;
text-overflow: ellipsis;
}
.m02-dfdg__pill-value {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.m02-dfdg__pill-value:empty {
display: none;
}
/* 선 — 굽은 선 */
.m02-dfdg__link {
pointer-events: auto;
cursor: pointer;
}
.m02-dfdg__hit {
fill: none;
stroke: transparent;
stroke-width: 12;
}
.m02-dfdg__wire {
fill: none;
stroke: var(--color-text-muted);
stroke-width: 1.5;
}
.m02-dfdg__link.is-strong .m02-dfdg__wire {
stroke: var(--color-accent);
stroke-width: 3;
}
.m02-dfdg__link.is-dim .m02-dfdg__wire {
opacity: 0.25;
}
.m02-dfdg__drag {
fill: none;
stroke: var(--cfdg-out);
stroke-width: 2;
stroke-dasharray: 5 4;
}
/* 함수 상자 흐린 「+」 받는 자리 */
.m02-dfdg__pill.is-plus {
justify-content: center;
min-width: 2rem;
border-style: dashed;
border-color: var(--color-text-muted);
background: transparent;
color: var(--color-text-muted);
opacity: 0.7;
}
.m02-dfdg__pill.is-plus:hover,
.m02-dfdg.is-linking .m02-dfdg__pill.is-plus.is-target {
opacity: 1;
border-color: var(--cfdg-in);
color: var(--cfdg-in);
}
@@ -0,0 +1,682 @@
/* =============================================================================
* M02_DF_Diagram.ts
* 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」)
* 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 ·
* 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) ·
* 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게.
* SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import {
portsIn,
portsOut,
type CfBox,
type CfKind,
type CfLogic,
type CfPill,
type CfPort,
} from "./M02_DF_Model";
import "./M02_DF_Diagram.css";
const TEXT = {
Title: ["다이어그램", "Diagram"],
K_input: ["입력", "Input"],
K_function: ["함수", "Function"],
K_output: ["출력", "Output"],
AddInput: ["+ 입력", "+ Input"],
AddFunc: ["+ 함수", "+ Function"],
AddOutput: ["+ 출력", "+ Output"],
ZoomIn: ["확대", "Zoom in"],
ZoomOut: ["축소", "Zoom out"],
Fit: ["한눈에", "Fit all"],
Arrange: ["줄 맞추기", "Line up"],
Cancel: ["취소", "Cancel"],
Connect: ["잇기", "Connect"],
Disconnect: ["끊기", "Disconnect"],
Remove: ["상자 지우기", "Remove box"],
AskRemove: [
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
"Remove box “{v}”? Its lines go too",
],
PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"],
Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"],
NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"],
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"],
NoMatch: [
"같은 이름 받는 알약 없음 — 알약에 바로 놓으세요",
"No pill with that name — drop on a pill",
],
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
From: ["어디서", "From"],
To: ["어디로", "To"],
Value: ["지금 값", "Now"],
PlusIn: [
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
"Drop an output pill here to add an input",
],
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
Hint: [
"상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대",
"Drag head to move · drag space to pan",
],
} as const;
const tt = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */
export const KIND_COLOR: Record<CfKind, string> = {
input: "#2a6fb0",
function: "#7a4fb5",
output: "#c2610c",
};
export interface DiagramOn {
select(id: string | null): void;
move(boxId: string, x: number, y: number): void;
/** 잇기 — 막히면 까닭(쉬운 말) */
link(from: CfPort, to: CfPort): string | null | void;
unlink(linkId: string): void;
add(kind: CfKind, x: number, y: number): void;
remove(boxId: string): void;
}
export interface DiagramOpts {
/** 상자 id → 알약 이름 → 지금 값 */
values?: Record<string, Record<string, unknown>>;
/** 고른 상자 id · 선 id */
selected?: string | null;
}
/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */
/** 알약 — Model 의 받는 · 내보내는 알약 */
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
return { ins: portsIn(box), outs: portsOut(box) };
}
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
export function resolveDrop(
lg: CfLogic,
from: CfPort,
target: { box: string; name: string | null; dir: "in" | "out" | null } | null,
): { to: CfPort } | { why: string } {
if (!target) return { why: tt("NotIn") };
if (target.box === from.box) return { why: tt("SameBox") };
if (target.dir === "out") return { why: tt("NotIn") };
const box = lg.boxes.find((b) => b.id === target.box);
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
if (box?.kind === "function" && target.name === null)
return { to: { box: box.id, name: from.name } };
const ins = box ? pillsOf(box).ins : [];
if (!box || !ins.length) return { why: tt("NoIn") };
const name =
target.name ??
ins.find((p) => p.name === from.name)?.name ??
(ins.length === 1 ? ins[0].name : null);
if (!name) return { why: tt("NoMatch") };
if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") };
return { to: { box: box.id, name } };
}
/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */
export function arrange(
lg: CfLogic,
gap = { x: 260, y: 130 },
): Record<string, { x: number; y: number }> {
const depth = new Map<string, number>(lg.boxes.map((b) => [b.id, 0]));
for (let round = 0; round < lg.boxes.length; round++) {
let moved = false;
for (const l of lg.links) {
const d = (depth.get(l.from.box) ?? 0) + 1;
if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) {
depth.set(l.to.box, d);
moved = true;
}
}
if (!moved) break;
}
const rows = new Map<number, number>();
const out: Record<string, { x: number; y: number }> = {};
for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) {
const d = depth.get(b.id) ?? 0;
const r = rows.get(d) ?? 0;
rows.set(d, r + 1);
out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y };
}
return out;
}
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
export const FIT_MIN = 0.9;
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
): { zoom: number; panX: number; panY: number } {
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
const place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
}
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
/* ── 그리기 ─────────────────────────────────────────────────────── */
interface HostState {
selected: string | null;
linking: CfPort | null;
pill: { box: string; name: string; dir: "in" | "out" } | null;
note: string;
zoom: number;
panX: number;
panY: number;
fitted: boolean;
watch?: ResizeObserver;
}
const states = new WeakMap<HTMLElement, HostState>();
const SVG = "http://www.w3.org/2000/svg";
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
export const showValue = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number")
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
if (Array.isArray(v)) {
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
}
return String(v).slice(0, 12);
};
export function renderDiagram(
host: HTMLElement,
lg: CfLogic,
on: DiagramOn,
opts: DiagramOpts = {},
): void {
const st: HostState = states.get(host) ?? {
selected: null,
linking: null,
pill: null,
note: "",
zoom: 1,
panX: 0,
panY: 0,
fitted: false,
};
if (opts.selected !== undefined) st.selected = opts.selected;
states.set(host, st);
const values = opts.values ?? {};
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected });
const select = (id: string | null): void => {
st.selected = id;
st.pill = null;
st.note = "";
on.select(id);
redraw();
};
const tryLink = (from: CfPort, target: Parameters<typeof resolveDrop>[2]): void => {
const got = resolveDrop(lg, from, target);
st.linking = null;
st.note = "why" in got ? got.why : on.link(from, got.to) || "";
redraw();
};
/* 판 · 세상(확대 · 옮김을 한 번에) */
const world = el("div", { className: "m02-dfdg__world" });
const svg = document.createElementNS(SVG, "svg");
svg.classList.add("m02-dfdg__lines");
world.append(svg);
const view = el("div", {
className: "m02-dfdg__view",
attrs: { "aria-label": tt("Title") },
children: [world],
});
const applyView = (): void => {
world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`;
zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`;
};
/** 화면 점 → 세상 자리 */
const toWorld = (cx: number, cy: number): { x: number; y: number } => {
const r = view.getBoundingClientRect();
return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom };
};
/* 상자 */
const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => {
const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name);
const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir;
const btn = el("button", {
className: [
"m02-dfdg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" "),
attrs: {
type: "button",
"data-box": b.id,
"data-name": p.name,
"data-dir": dir,
title: `${p.name} (${p.unit || "-"})`,
},
children: [
el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }),
el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }),
],
});
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir });
st.pill = chosen ? null : { box: b.id, name: p.name, dir };
st.linking = null;
st.note = "";
redraw();
});
if (dir === "out")
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
return btn;
};
/** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */
const plusSlot = (b: CfBox): HTMLElement => {
const slot = el("button", {
className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`,
text: "+",
attrs: {
type: "button",
"data-box": b.id,
"data-dir": "in",
"data-plus": "1",
title: tt("PlusIn"),
},
});
slot.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" });
st.note = tt("PlusIn");
redraw();
});
return slot;
};
const boxEl = (b: CfBox): HTMLElement => {
const { ins, outs } = pillsOf(b);
const head = el("div", {
className: "m02-dfdg__head",
children: [
el("span", { className: "m02-dfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }),
el("strong", { className: "m02-dfdg__name", text: b.name }),
el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }),
],
});
const node = el("div", {
className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`,
attrs: {
"data-box": b.id,
tabindex: "0",
role: "button",
"aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`,
},
children: [
head,
el("div", {
className: "m02-dfdg__pills",
children: [
el("div", {
className: "m02-dfdg__ins",
children: [
...ins.map((p) => pillEl(b, p, "in")),
...(b.kind === "function" ? [plusSlot(b)] : []),
],
}),
el("div", {
className: "m02-dfdg__outs",
children: outs.map((p) => pillEl(b, p, "out")),
}),
],
}),
],
});
node.style.left = `${b.x}px`;
node.style.top = `${b.y}px`;
node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]);
node.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null });
select(st.selected === b.id ? null : b.id);
});
node.addEventListener("keydown", (ev) => {
if (ev.target !== node) return;
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
node.click();
}
const step = ev.shiftKey ? 50 : 10;
const d = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
}[ev.key];
if (d && st.selected === b.id) {
ev.preventDefault();
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
}
});
head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node));
return node;
};
world.append(...lg.boxes.map(boxEl));
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
const setZoom = (z: number, cx?: number, cy?: number): void => {
const r = view.getBoundingClientRect();
const px = cx ?? r.left + r.width / 2;
const py = cy ?? r.top + r.height / 2;
const at = toWorld(px, py);
st.zoom = clampZoom(z);
st.panX = px - r.left - at.x * st.zoom;
st.panY = py - r.top - at.y * st.zoom;
applyView();
drawLines();
};
const fit = (): void => {
const rects = lg.boxes.map((b) => {
const n = world.querySelector<HTMLElement>(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`);
return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 };
});
const r = view.getBoundingClientRect();
Object.assign(st, fitView(rects, { w: r.width, h: r.height }));
applyView();
drawLines();
};
const freeSpot = (): { x: number; y: number } => {
const r = view.getBoundingClientRect();
const c = toWorld(r.left + r.width / 3, r.top + r.height / 3);
let y = c.y;
while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100;
return { x: Math.round(c.x), y: Math.round(y) };
};
/** 작은 단추 */
const sm = (
label: keyof typeof TEXT,
onClick: () => void,
variant: "ghost" | "filled" | "danger" = "ghost",
) => createButton({ label: tt(label), size: "sm", variant, onClick });
const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement =>
sm(label, () => {
const at = freeSpot();
on.add(kind, at.x, at.y);
});
const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`;
const selLink = lg.links.find((l) => l.id === st.selected) ?? null;
const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null;
const askRemove = async (b: CfBox): Promise<void> => {
if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id);
};
const tools: HTMLElement[] = [];
if (st.linking) {
tools.push(
el("span", { text: tt("PickTarget", { v: st.linking.name }) }),
sm("Cancel", () => ((st.linking = null), redraw())),
);
} else if (st.pill) {
const p = st.pill;
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name;
const where = lg.links
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
.map((l) => endName(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`;
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") }));
if (p.dir === "out")
tools.push(
sm(
"Connect",
() => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()),
"filled",
),
);
} else if (selLink) {
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: line }),
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
);
} else if (selBox) {
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
}
const bar = el("div", {
className: "m02-dfdg__bar",
children: [
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
el("span", { className: "m02-dfdg__gap" }),
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
zoomLabel,
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
sm("Fit", fit),
sm("Arrange", () => {
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
}),
],
});
const root = el("div", {
className: `m02-dfdg${st.linking ? " is-linking" : ""}`,
children: [
bar,
el("div", {
className: "m02-dfdg__tools",
children: [
...tools,
el("span", { className: "m02-dfdg__note", text: st.note }),
el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }),
],
}),
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
],
});
root.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && (st.linking || st.pill)) {
st.linking = null;
st.pill = null;
redraw();
}
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
if (selLink) on.unlink(selLink.id);
else if (selBox) void askRemove(selBox);
});
view.addEventListener("click", (ev) => {
if (ev.target === view || ev.target === world) select(null);
});
view.addEventListener(
"wheel",
(ev) => {
ev.preventDefault();
setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY);
},
{ passive: false },
);
view.addEventListener("pointerdown", (ev) => {
if (ev.target !== view && ev.target !== world && ev.target !== svg) return;
dragPan(ev);
});
host.replaceChildren(root);
applyView();
/* 선 — 알약 자리를 재서 세상 좌표로 */
function drawLines(): void {
svg.replaceChildren();
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
const pill = world.querySelector<HTMLElement>(
`.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
);
if (!pill) return null;
const r = pill.getBoundingClientRect();
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
};
for (const l of lg.links) {
const a = at(l.from, "out");
const b = at(l.to, "in");
if (!a || !b) continue;
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
const strong =
st.selected === l.id ||
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id));
const g = document.createElementNS(SVG, "g");
g.setAttribute(
"class",
`m02-dfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`,
);
g.setAttribute("data-link", l.id);
g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","));
for (const cls of ["m02-dfdg__hit", "m02-dfdg__wire"]) {
const p = document.createElementNS(SVG, "path");
p.setAttribute("d", d);
p.setAttribute("class", cls);
g.append(p);
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(st.selected === l.id ? null : l.id);
});
svg.append(g);
}
}
const firstFit = (): void => {
if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines();
st.fitted = true;
fit();
};
st.watch?.disconnect();
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit);
st.watch?.observe(view);
if (view.isConnected) firstFit();
/* 끌기 셋 — 상자 · 판 · 선 */
function follow(
ev: PointerEvent,
move: (m: PointerEvent) => void,
end: (u: PointerEvent, moved: boolean) => void,
): void {
if (ev.button !== 0) return;
const x0 = ev.clientX;
const y0 = ev.clientY;
let moved = false;
const mv = (m: PointerEvent): void => {
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
moved = true;
move(m);
};
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
if (moved) {
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
const swallow = (c: MouseEvent): void => c.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
}
end(u, moved);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
const start = toWorld(ev.clientX, ev.clientY);
let x = b.x;
let y = b.y;
follow(
ev,
(m) => {
const now = toWorld(m.clientX, m.clientY);
x = Math.round(b.x + now.x - start.x);
y = Math.round(b.y + now.y - start.y);
node.style.left = `${x}px`;
node.style.top = `${y}px`;
drawLines();
},
(_u, moved) => {
if (moved) on.move(b.id, x, y);
},
);
}
function dragPan(ev: PointerEvent): void {
const p0 = { x: st.panX, y: st.panY };
follow(
ev,
(m) => {
st.panX = p0.x + m.clientX - ev.clientX;
st.panY = p0.y + m.clientY - ev.clientY;
applyView();
},
() => undefined,
);
}
function dragLink(ev: PointerEvent, from: CfPort): void {
ev.stopPropagation();
const pill = ev.currentTarget as HTMLElement;
const r = pill.getBoundingClientRect();
const a = toWorld(r.right, r.top + r.height / 2);
let line: SVGPathElement | null = null;
follow(
ev,
(m) => {
const b = toWorld(m.clientX, m.clientY);
if (!line) {
line = document.createElementNS(SVG, "path");
line.setAttribute("class", "m02-dfdg__drag");
svg.append(line);
}
line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`);
},
(u, moved) => {
if (!moved) return;
line?.remove();
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
const p = hit?.closest<HTMLElement>(".m02-dfdg__pill");
const bx = hit?.closest<HTMLElement>(".m02-dfdg__box");
tryLink(
from,
p
? {
box: p.dataset.box ?? "",
name: p.dataset.name ?? null,
dir: (p.dataset.dir as "in" | "out") ?? null,
}
: bx
? { box: bx.dataset.box ?? "", name: null, dir: null }
: null,
);
},
);
}
}
@@ -0,0 +1,220 @@
/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */
.m02-df {
position: fixed;
inset: 0;
z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */
overflow-x: hidden;
overflow-y: auto;
background: var(--color-canvas);
color: var(--color-text);
font-size: var(--text-body);
}
.m02-df:focus {
outline: none;
}
.m02-df__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
/* 위 띠 — 밀어도 위에 붙어 있음 */
.m02-df__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
padding: var(--spacing-12) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-raised);
}
.m02-df__title {
margin: 0;
font-size: var(--text-subheading);
}
.m02-df__trial {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-df__strip {
display: flex;
flex: 1 1 480px;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
}
.m02-df__name {
flex: 1 1 260px;
}
.m02-df__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
.m02-df__body {
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;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--spacing-24);
}
.m02-df__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-df__start-col h3 {
margin: var(--spacing-8) 0 0;
}
/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */
.m02-df__layout {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.m02-df__part {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m02-df__part h3 {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m02-df__part-body {
padding: var(--spacing-12);
}
.m02-df__lower {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-df__box .m02-df__part-body {
min-height: 50vh;
}
.m02-df__box-slots {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing-12);
}
.m02-df__slot {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-df__slot h4 {
margin: 0;
}
@media (max-width: 1100px) {
.m02-df__lower,
.m02-df__start,
.m02-df__box-slots {
grid-template-columns: minmax(0, 1fr);
}
}
.m02-df__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-df__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-df__item:hover {
border-color: var(--color-accent);
}
.m02-df__item-name {
flex: 1;
min-width: 0;
}
.m02-df__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
.m02-df__note {
margin: 0 0 var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m02-df__note:empty {
display: none;
}
.m02-df__color {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
margin-bottom: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m02-df__color[hidden] {
display: none;
}
.m02-df__color-input {
width: 44px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
+579
View File
@@ -0,0 +1,579 @@
/* =============================================================================
* M02_DF_Entry.ts
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게.
* 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary
* → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S.
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfListItem, CfQuery } from "./M02_DF_Api";
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model";
import "./M02_DF_Entry.css";
/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
const loadMods = () =>
Promise.all([
import("./M02_DF_Model"),
import("./M02_DF_Eval"),
import("./M02_DF_Api"),
import("./M02_DF_Diagram"),
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({
model,
ev,
api,
diagram,
func,
output,
summary,
sample,
input,
}));
type Mods = Awaited<ReturnType<typeof loadMods>>;
const TEXT = {
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
Title: ["단가산출 다이어그램", "Cost diagram"],
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
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"],
SaveAnyway: [
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
"There are issues — {v} · Save anyway?",
],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
Differs: [
"저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름",
"Recalculated on save: {s} — differs from the screen value {c}",
],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Deleted: ["지웠음", "Deleted"],
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
async function unitChoices(): Promise<string[]> {
const count = new Map<string, number>();
try {
for (const x of await fetchLogics()) {
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
}
} catch {
/* 못 받으면 기본 하나 */
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
}
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const els = inp.elements
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
.map((e) => {
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
if (field) q.field = field;
return { q, box: b.id, port: e.name };
});
const table = inp.table
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
: [];
return [...els, ...table];
});
}
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
const asks = queriesOf(lg);
if (!asks.length) return { values: {}, errors: [] };
const answers = await M.api.cfValues(
asks.map((a) => a.q),
lg.userElements as never,
);
const values: CfValues = {};
const errors: string[] = [];
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
}
return { values, errors };
}
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createDataFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openDataFlow });
}
function openDataFlow(): void {
/** 고치는 화면이 열려 있을 때만 */
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
let units: string[] | null = null;
const mods = loadMods();
const mayLeave = async (): Promise<boolean> =>
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const body = el("main", { className: "m02-df__body" });
const strip = el("div", { className: "m02-df__strip" });
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) paper ? void showStart() : shut();
})(),
});
const page = el("div", {
className: "m02-df",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") },
children: [
el("header", {
className: "m02-df__head",
children: [
el("h2", { className: "m02-df__title", text: te("Title") }),
el("span", { className: "m02-df__trial", text: te("Trial") }),
strip,
close,
],
}),
body,
],
});
const prevOverflow = document.body.style.overflow;
const shut = (): void => {
page.remove();
document.body.style.overflow = prevOverflow;
};
page.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented) return;
if (ev.key === "Escape") {
void (async () => {
if (await mayLeave()) shut();
})();
return;
}
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
});
const run = (job: (M: Mods) => Promise<void>): void => {
body.replaceChildren(muted(te("Loading")));
void mods.then(job).catch((e: unknown) => {
showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error");
void showStart();
});
};
/* ---------- 시작 화면 ---------- */
const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
const row = el("button", {
className: "m02-df__item",
attrs: { type: "button", title },
children: [
el("span", { className: "m02-df__item-name", text: name }),
el("span", { className: "m02-df__item-unit", text: unit }),
],
});
row.addEventListener("click", onClick);
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
const sampleHost = el("div", { className: "m02-df__list" });
body.replaceChildren(
el("div", {
className: "m02-df__start",
children: [
el("section", {
className: "m02-df__start-col",
children: [
bigButton(te("Start_New"), () =>
run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)),
),
el("h3", { text: te("Start_Mine") }),
mineHost,
],
}),
el("section", {
className: "m02-df__start-col",
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
}),
],
}),
);
const M = await mods;
sampleHost.replaceChildren(
...M.sample.sampleList().map((s) =>
listRow(
s.name,
s.resultUnit,
() =>
run(async (M2) => {
const lg = M2.sample.sampleLoad(s.id);
if (lg) await showPaper(M2, lg, false);
}),
s.note,
),
),
);
try {
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
mineHost.replaceChildren(
...(mine.length
? mine.map((x) =>
listRow(
x.name,
x.resultUnit,
() => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)),
x.saved,
),
)
: [muted(te("Start_MineNone"))]),
);
} catch (e) {
mineHost.replaceChildren(muted(M.api.cfPlain(e)));
}
};
const units_ = async (): Promise<string[]> => (units ??= await unitChoices());
/* ---------- 고치는 화면 ---------- */
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
const choices = await units_();
let lg = first;
let savedLg: CfLogic | null = saved ? first : null;
let stack: CfLogic[] = [];
let selected: string | null = null;
let values: CfValues = {};
let valueSig = "";
let notes: string[] = [];
let result: CfEvalResult;
let busy = false;
const 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 colorRow = el("label", {
className: "m02-df__color",
children: [el("span", { text: te("Color") }), colorBox],
});
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김)
const funcHost = el("div");
const outHost = el("div");
const inHost = el("div");
const summaryHost = el("div");
const evalNow = (): CfEvalResult => {
try {
return M.ev.evalLogic(lg, values);
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
}
};
const edit = (next: CfLogic): void => {
if (next === lg) return;
stack = [...stack, lg];
lg = next;
redraw();
};
/** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */
const editFromDiagram = (next: CfLogic): void => {
edit(next);
setTimeout(redraw);
};
/** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
const refreshValues = (): void => {
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(M, lg).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : [];
redraw();
},
(e: unknown) => {
notes = [te("Values", { v: M.api.cfPlain(e) })];
redraw();
},
);
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: lg.name,
onInput: (v) => edit({ ...lg, name: v }),
});
name.root.classList.add("m02-df__name");
const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices];
const unit = createSelectField({
label: te("Unit"),
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
value: lg.resultUnit,
onChange: (v) => edit({ ...lg, resultUnit: v }),
});
const diagramOn = {
select: (id: string | null): void => {
selected = id;
drawPanel();
},
move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
const got = M.model.link(lg, from, to);
if (got.why) return got.why;
editFromDiagram(got.lg);
return null;
},
unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)),
add: (kind: CfKind, x: number, y: number): void => {
const got = M.model.addBox(lg, kind, x, y);
selected = got.box.id;
editFromDiagram(got.lg);
},
remove: (id: string): void => {
if (selected === id) selected = null;
editFromDiagram(M.model.removeBox(lg, id));
},
};
const drawPanel = (): void => {
const box = lg.boxes.find((b) => b.id === selected);
pickHint.hidden = !!box;
colorRow.hidden = !box;
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
funcHost.hidden = box?.kind !== "function";
outHost.hidden = box?.kind !== "output";
inHost.hidden = box?.kind !== "input";
if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit });
if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit });
if (box?.kind === "output")
M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
};
const redraw = (): void => {
result = evalNow();
refreshValues();
unsaved.hidden = lg === savedLg;
undo.disabled = !stack.length;
deleteButton.hidden = !lg.key;
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
drawPanel();
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
// ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠)
(M.summary.renderSummary as (...args: unknown[]) => void)(
summaryHost,
lg,
result,
{ change: edit },
{ values },
);
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
};
const save = async (): Promise<void> => {
if (busy) return;
if (!lg.name.trim()) {
note.textContent = te("NoName");
return void name.input.focus();
}
let problems: string[] = [];
try {
problems = M.ev.checkLogic(lg, values);
} catch {
problems = [];
}
const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return;
busy = true;
note.textContent = te("Saving");
const screen = result.cost.계;
try {
const got = await M.api.cfSave(lg);
lg = got.logic;
savedLg = lg;
stack = [];
redraw();
const server = got.values.cost.계;
if (Math.abs(server - screen) > 0.5)
notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
note.textContent = [...notes, ...result.errors].join("\n");
showToast(te("Saved"), "success");
} catch (e) {
note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) });
} finally {
busy = false;
}
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back) return;
stack = stack.slice(0, -1);
lg = back;
name.input.value = lg.name;
unit.select.value = lg.resultUnit;
redraw();
};
const doDelete = async (): Promise<void> => {
if (!lg.key || busy) return;
if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return;
try {
await M.api.cfDelete(lg.key);
savedLg = lg;
showToast(te("Deleted"), "success");
void showStart();
} catch (e) {
note.textContent = M.api.cfPlain(e);
}
};
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
onClick: () => void doDelete(),
});
strip.replaceChildren(
name.root,
unit.root,
unsaved,
el("div", {
className: "m02-df__actions",
children: [
undo,
createButton({ label: te("Save"), onClick: () => void save() }),
deleteButton,
],
}),
);
body.replaceChildren(
note,
el("div", {
className: "m02-df__layout",
children: [
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]),
],
}),
],
}),
);
paper = { dirty: () => lg !== savedLg, save, undo: doUndo };
redraw();
if (!lg.name) name.input.focus();
};
void showStart();
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
document.body.append(page);
page.tabIndex = -1;
page.focus();
}
/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */
function part(title: string, className: string, children: HTMLElement[]): HTMLElement {
return el("section", {
className: `m02-df__part ${className}`,
children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })],
});
}
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m02-df__big");
return button;
}
+210
View File
@@ -0,0 +1,210 @@
/* =============================================================================
* M02_DF_Eval.ts
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_DF_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
export type CfValue = number | string | boolean | null | (number | string | null)[][];
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
export type CfValues = Record<string, Record<string, CfValue>>;
type Scalar = number | string | boolean | null;
export interface CfEvalResult {
/** 상자 id → 내보내는 알약 이름 → 값 */
ports: Record<string, Record<string, CfValue>>;
/** 출력 상자 id → 값 */
outputs: Record<string, Scalar>;
cost: {
노무비: number;
재료비: number;
경비: number;
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
값들: Record<string, Scalar>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
const TEXT = {
/** {box} {cell} {err} */
CellError: [
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
"「{box}」 cell {cell} can't be worked out — {err}",
],
Cycle: ["돌고 도는 참조", "circular reference"],
/** {box} */
Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"],
NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"],
/** {box} {pill} */
EmptyIn: [
"「{box}」 의 받는 알약 「{pill}」 에 선이 없음",
"Pill 「{pill}」 of 「{box}」 has no line",
],
NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"],
NoOutput: ["출력 상자가 없음", "There is no output box"],
NoInCell: [
"「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음",
"Pill 「{pill}」 of 「{box}」 is not placed in a cell",
],
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
} as const;
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, v),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
/* ── 차례 ───────────────────────────────────────────────────────────────── */
/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */
function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } {
const funcs = lg.boxes.filter((b) => b.kind === "function");
const done: CfBox[] = [];
const left = new Set(funcs.map((f) => f.id));
for (let moved = true; moved && left.size;) {
moved = false;
for (const f of funcs) {
if (!left.has(f.id)) continue;
const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box));
if (waits) continue;
done.push(f);
left.delete(f.id);
moved = true;
}
}
return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) };
}
/* ── 칸 ─────────────────────────────────────────────────────────────────── */
function putValue(칸: Record<string, Cell>, cell: string, value: Scalar): void {
const style = 칸[cell]?.서식;
if (value === null) {
if (style) 칸[cell] = { 서식: style };
else delete 칸[cell];
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
}
/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */
function put(칸: Record<string, Cell>, cell: string, value: CfValue): void {
const at = parseA1(cell);
if (!at) return;
if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value);
value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x)));
}
const fromCalc = (v: CalcValue | undefined): Scalar => {
if (!v) return null;
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
};
/* ── 계산 ───────────────────────────────────────────────────────────────── */
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, CfValue> {
const given = values[box.id] ?? {};
const pills = portsOut(box);
const out: Record<string, CfValue> = {};
const inp = box.input;
if (!inp) return out;
// portsOut 차례 = 요소 · 표 · 변수
const sources: CfValue[] = [
...(inp.mode === "group"
? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null)
: []),
...(inp.mode === "group" && inp.table ? [null] : []),
...inp.variables.map((v) => v.example),
];
pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null)));
return out;
}
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */
function runFunc(
box: CfBox,
incoming: Record<string, CfValue>,
given: Record<string, CfValue>,
errors: string[],
): Scalar {
const func = box.func!;
const sheet: Sheet = structuredClone(func.sheet);
for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null);
for (const v of func.variables)
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
for (const [cell, v] of Object.entries(values[sheet.id] ?? {}))
if ("오류" in v) {
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
errors.push(tl("CellError", { box: box.name, cell, err }));
}
const at = func.output.cell.toUpperCase();
const c = sheet.칸[at];
if (!c) return null;
return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
}
/** 화면 계산 */
export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult {
const errors: string[] = [];
const ports: CfEvalResult["ports"] = {};
for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values);
const into = (box: string): Record<string, CfValue> =>
Object.fromEntries(
lg.links
.filter((l) => l.to.box === box)
.map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]),
);
const { funcs, loop } = order(lg);
for (const f of loop) errors.push(tl("Loop", { box: f.name }));
for (const f of funcs)
ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) };
const outputs: CfEvalResult["outputs"] = {};
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
for (const b of lg.boxes.filter((x) => x.kind === "output")) {
const v = into(b.id)[OUTPUT_PORT] ?? null;
const scalar: Scalar = Array.isArray(v) ? null : v;
outputs[b.id] = scalar;
const kind = b.output?.cost ?? "값";
if (kind === "값") cost.값들[b.name] = scalar;
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
}
cost.계 = cost.노무비 + cost.재료비 + cost.경비;
return { ports, outputs, cost, errors };
}
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] {
const out: string[] = [];
for (const b of lg.boxes) {
for (const p of portsIn(b))
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
if (b.kind === "function" && !parseA1(b.func?.output.cell ?? ""))
out.push(tl("NoCell", { box: b.name }));
for (const i of b.func?.inputs ?? [])
if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port }));
}
if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput"));
const ev = evalLogic(lg, values);
for (const b of lg.boxes.filter((x) => x.kind === "input"))
for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {}))
if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill))
out.push(tl("NoValue", { box: b.name, pill }));
return [...out, ...ev.errors];
}
@@ -0,0 +1,180 @@
.m02-dfpanel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dfpanel-head,
.m02-dfpanel-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfpanel-head {
justify-content: space-between;
}
.m02-dfpanel-title {
font-size: var(--text-subheading);
}
.m02-dfpanel-unitbox {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
.m02-dfpanel-unit {
width: 6em;
text-align: center;
}
.m02-dfpanel-cell {
padding: 2px var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
}
.m02-dfpanel-label {
font-weight: var(--font-weight-bold);
}
.m02-dfpanel-note {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dfpanel .ui-btn,
.m02-dfpanel .ui-input,
.m02-dfpanel .ui-select {
min-height: 44px;
font-size: var(--text-body);
}
.m02-dfpanel-form {
align-items: flex-end;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
}
.m02-dfpanel-fieldbox {
display: inline-flex;
flex-direction: column;
gap: 2px;
}
.m02-dfpanel-field {
width: 9em;
}
.m02-dfpanel-field.is-num {
width: 6em;
}
.m02-dfpanel-why,
.m02-dfpanel-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);
}
.m02-dfpanel-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.m02-dfpanel-chip {
cursor: pointer;
}
button.m02-dfpanel-chip:focus-visible {
outline: 2px solid var(--color-focus-ring);
}
.m02-dfpanel-chip.is-in {
border-color: #3b82f6;
}
.m02-dfpanel-chip.is-in:not(.is-placed) {
border-style: dashed;
}
.m02-dfpanel-chip.is-out {
border-color: #f59e0b;
}
.m02-dfpanel-chip.is-var {
border-color: #22a06b;
}
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
.m02-dfpanel-sheet {
position: relative;
height: max(640px, calc(100vh - 260px));
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
overflow: hidden;
}
/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */
.m02-dfpanel .aislo-cell[data-cf-kind="in"] {
box-shadow: inset 0 0 0 2px #3b82f6;
}
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
box-shadow: inset 0 0 0 2px #22a06b;
}
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
box-shadow: inset 0 0 0 2px #f59e0b;
}
.m02-dfpanel .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;
}
.m02-dfpanel .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;
}
.m02-dfpanel .aislo-cell[data-cf-err] {
outline: 2px dashed var(--color-danger);
outline-offset: -2px;
}
@@ -0,0 +1,700 @@
/* =============================================================================
* M02_DF_FuncPanel.ts
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_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_DF_Model";
import "./M02_DF_FuncPanel.css";
const TEXT = {
Unit: ["단위", "Unit"],
Cell: ["고른 칸", "Selected cell"],
Got: ["받은 알약", "Received pills"],
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
Placed: ["놓임", "placed"],
Free: ["아직 안 놓은 알약", "not placed yet"],
Busy: ["이미 쓰는 칸", "That cell is already in use"],
Locked: [
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
"This cell is filled by the wire — cannot edit",
],
Vars: ["변수", "Variables"],
MakeVar: ["변수 만들기", "Make variable"],
NoVars: ["아직 변수가 없음", "No variables yet"],
VarName: ["이름", "Name"],
VarType: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
VarUnit: ["단위", "Unit"],
VarMin: ["최소", "Min"],
VarMax: ["최대", "Max"],
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
VarExample: ["예시값", "Example value"],
VarCell: ["붙을 칸", "Attach to"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Remove: ["지움", "Remove"],
NeedName: ["이름을 적음", "Enter a name"],
SameName: ["같은 이름이 이미 있음", "That name already exists"],
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
OutBtn: ["출력 칸으로", "Make output cell"],
OutNow: ["출력", "Output"],
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
Frames: ["틀", "Templates"],
FrameMul: ["곱하기", "Multiply"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
ReadOnly: ["읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
Problem: ["⚠ ", "⚠ "],
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMul", "=B1*B2"],
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
type Handlers = { change(lg: CfLogic): void };
interface VarForm {
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 || "-");
}
// 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리)
for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-");
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 && i.cell)) 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.filter((x) => x.cell))
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.filter((i) => i.cell).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 m02-dfpanel-unit",
attrs: { type: "text", "aria-label": tf("Unit") },
});
unit.value = box?.unit ?? "-";
if (st.readOnly) unit.readOnly = true;
unit.addEventListener("change", () => {
const v = unit.value.trim() || "-";
unit.value = v;
patchBox(st, (b) => ({
...b,
unit: v,
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func,
}));
});
st.parts.head.replaceChildren(
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
el("span", { className: "m02-dfpanel-cell", text: `${tf("Cell")} ${st.cell}` }),
el("label", {
className: "m02-dfpanel-unitbox",
children: [el("span", { className: "m02-dfpanel-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: `m02-dfpanel-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: "m02-dfpanel-label", text: tf("Got") }),
...(chips.length ? chips : [el("span", { className: "m02-dfpanel-note", text: tf("NoGot") })]),
...(chips.length && !st.readOnly
? [el("span", { className: "m02-dfpanel-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 m02-dfpanel-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: "m02-dfpanel-fieldbox",
children: [el("span", { className: "m02-dfpanel-note", text: tf(label) }), input],
});
}
function drawVars(st: State): void {
const f = funcOf(st);
const row: HTMLElement[] = [el("span", { className: "m02-dfpanel-label", text: tf("Vars") })];
for (const v of f?.variables ?? []) {
const chip = el("span", {
className: "m02-dfpanel-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: "m02-dfpanel-note", text: tf("NoVars") }));
const parts: HTMLElement[] = [el("div", { className: "m02-dfpanel-row", children: row })];
if (!st.readOnly) {
const form = st.form;
if (!form) {
parts.push(
el("div", {
className: "m02-dfpanel-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 m02-dfpanel-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: "m02-dfpanel-row m02-dfpanel-form",
children: [
formField(st, "VarName", "name"),
el("label", {
className: "m02-dfpanel-fieldbox",
children: [el("span", { className: "m02-dfpanel-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: "m02-dfpanel-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: "m02-dfpanel-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: "m02-dfpanel-note", text: tf("ReadOnly") }),
);
return;
}
const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone");
st.parts.bar.replaceChildren(
el("div", {
className: "m02-dfpanel-row",
children: [
createButton({
label: tf("OutBtn"),
variant: "ghost",
size: "sm",
onClick: () => setOutput(st, st.cell),
}),
el("span", { className: "m02-dfpanel-chip is-out", text: outNow }),
el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }),
],
}),
el("div", {
className: "m02-dfpanel-row",
children: [
el("span", { className: "m02-dfpanel-label", text: tf("Frames") }),
...FRAMES.map(([key, formula]) =>
createButton({
label: tf(key),
variant: "ghost",
size: "sm",
onClick: () => putFormula(st, formula),
}),
),
el("span", { className: "m02-dfpanel-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) => i.cell && 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: "m02-dfpanel-head" }),
got: el("div", { className: "m02-dfpanel-row" }),
vars: el("div", { className: "m02-dfpanel-vars" }),
bar: el("div", { className: "m02-dfpanel-bar" }),
msg: el("p", { className: "m02-dfpanel-msg", attrs: { role: "status" } }),
sheet: el("div", { className: "m02-dfpanel-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("m02-dfpanel");
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,145 @@
.m02-dfip {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
color: var(--color-text);
font-size: var(--text-body-lg, 1.05rem);
}
.m02-dfip h4,
.m02-dfip h5 {
margin: 0;
color: var(--color-text-secondary);
}
.m02-dfip h4 {
font-size: 1.15em;
color: var(--color-text);
}
.m02-dfip__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dfip__unit {
margin: 0;
font-weight: 600;
}
.m02-dfip__body,
.m02-dfip__chosen,
.m02-dfip__find,
.m02-dfip__stack,
.m02-dfip__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-dfip__list {
max-height: 28vh;
overflow: auto;
}
.m02-dfip__chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m02-dfip__chip,
.m02-dfip__row {
padding: var(--spacing-8) var(--spacing-12);
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.m02-dfip__chip {
border-radius: var(--radius-pills);
padding: var(--spacing-4) var(--spacing-12);
}
.m02-dfip__chip.is-on {
border-color: var(--color-accent);
outline: 2px solid var(--color-accent);
}
.m02-dfip__chip:hover,
.m02-dfip__chip:focus-visible,
.m02-dfip__row:hover,
.m02-dfip__row:focus-visible {
border-color: var(--color-accent);
}
.m02-dfip__row {
display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
text-align: left;
}
.m02-dfip__rowbox {
display: flex;
gap: var(--spacing-8);
align-items: center;
}
.m02-dfip__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m02-dfip__bar > :last-child {
flex: 1 1 180px;
}
.m02-dfip__item {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.m02-dfip__name {
flex: 1 1 auto;
min-width: 0;
}
.m02-dfip__value {
color: var(--color-accent);
}
.m02-dfip__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
font-size: var(--text-caption);
}
.m02-dfip__new,
.m02-dfip__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
}
.m02-dfip__new > summary {
cursor: pointer;
font-weight: 600;
}
.m02-dfip__preview table {
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m02-dfip__preview th,
.m02-dfip__preview td {
padding: 2px 8px;
border: 1px solid var(--color-border);
white-space: nowrap;
text-align: center;
}
.m02-dfip__preview tr.is-hit td {
background: rgba(240, 190, 40, 0.35);
font-weight: 700;
}
.m02-dfip__preview {
max-height: 24vh;
overflow: auto;
}
@@ -0,0 +1,459 @@
/* =============================================================================
* M02_DF_InputPanel.ts
* 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) ·
* 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위
* M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/df/user-elements` = 시험 폴더 `_data`)
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubs,
type SubInfo,
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Api";
import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
import {
GROUPS,
M01_OF,
MACHINE_SEP,
USER_PREFIX,
addElement,
elementOf,
machineFields,
queryOf,
reconcileUnit,
removeElement,
setKind,
valueOf,
type M01Row,
} from "./M02_DF_InputPanel_Build";
import { renderTablePick } from "./M02_DF_InputPanel_Table";
import { renderVariables } from "./M02_DF_InputPanel_Vars";
import "./M02_DF_InputPanel.css";
/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */
interface Answer {
id: string;
ok: boolean;
value?: number | string | null;
}
interface UserEl {
id: string;
group: string;
name: string;
spec: string;
unit: string;
value: number | null;
}
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
const apiUsers = cfUserElements as unknown as () => Promise<UserEl[]>;
const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise<UserEl>;
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
Variable: ["변수", "Variable"],
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
유가전력: ["유가·전력", "Fuel · power"],
기계: ["기계", "Machine"],
소요량: ["소요량", "Quantity table"],
계수: ["계수", "Factor table"],
환율: ["환율", "Exchange rate"],
요율: ["요율", "Rate"],
Chosen: [
"넣은 요소 — 알약마다 이름 · 값 · 단위",
"Chosen elements — name · value · unit per pill",
],
NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"],
Remove: ["빼기", "Remove"],
Put: ["넣기", "Add"],
Search: ["이름으로 찾기", "Search by name"],
AllSub: ["전체", "All"],
SubLabel: ["구분", "Group"],
DetailLabel: ["상세구분", "Detail"],
Loading: ["찾는 중…", "Searching…"],
NoRow: ["찾는 요소 없음", "Nothing found"],
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
Mine: ["내가 만든 것", "Mine"],
NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"],
NewName: ["이름", "Name"],
NewSpec: ["규격", "Spec"],
NewUnit: ["단위", "Unit"],
NewValue: ["값(숫자)", "Value (number)"],
Make: ["만들어 넣기", "Create and add"],
NeedName: ["이름을 적어 주세요", "Enter a name"],
NeedValue: ["값은 숫자로", "Value must be a number"],
BoxUnit: ["상자 단위", "Box unit"],
Field: ["값 칸", "Value"],
NoValue: ["—", "—"],
Failed: ["불러오지 못함", "Could not load"],
Pills: ["알약 {n} 개", "{n} pills"],
NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"],
} 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");
/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */
const plain = (e: unknown): string => {
try {
return cfPlain(e);
} catch {
return e instanceof Error ? e.message : t("Failed");
}
};
const fileCache = new Map<string, string>();
/** 그룹의 M01 파일 이름 */
async function fileOf(group: CfGroup): Promise<string> {
const hit = fileCache.get(group);
if (hit) return hit;
const { group: g, part } = M01_OF[group];
const files = await fetchFiles(g);
const f = files.find((x) => !part || x.file.includes(part)) ?? files[0];
fileCache.set(group, f.file);
return f.file;
}
/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */
export function renderInputPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
host.className = "m02-dfip";
const box = lg.boxes.find((b) => b.id === boxId);
if (!box || box.kind !== "input") {
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("NotInput") }));
return;
}
// 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감
const fixed = reconcileUnit(lg, boxId);
if (fixed !== lg) return on.change(fixed);
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
const body = el("div", { className: "m02-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;
}),
});
const unit = el("p", {
className: "m02-dfip__unit",
text: `${t("BoxUnit")}: ${box.unit || "-"}`,
});
host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body);
if (kind === "변수") renderVariables(body, lg, boxId, on);
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
else renderGroup(body, lg, box, kind, on);
}
/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */
function renderGroup(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const chosen = el("div", { className: "m02-dfip__chosen" });
const find = el("div", { className: "m02-dfip__find" });
host.replaceChildren(chosen, find, newForm(lg, box, group, on));
drawChosen(chosen, lg, box, group, on);
void drawFind(find, lg, box, group, on);
}
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */
function drawChosen(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const els = box.input?.elements ?? [];
const values = new Map<string, HTMLElement>();
host.replaceChildren(
el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }),
...(els.length
? els.map((e) => {
const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") });
values.set(e.ref, val);
return el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: e.name }),
val,
el("span", { className: "m02-dfip__muted", text: e.unit || "-" }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeElement(lg, box.id, e.ref)),
}),
],
});
})
: [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]),
);
if (!els.length) return;
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
void apiValues(items, lg.userElements).then(
(answers) =>
answers.forEach((a) => {
const node = values.get(a.id);
if (node && a.ok && a.value !== null && a.value !== undefined)
node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value);
}),
// Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로)
() =>
els.forEach(
(e) =>
void m01Value(group, e).then((v) => {
const node = values.get(e.ref);
if (node && v !== null) node.textContent = fmt(v);
}),
),
);
}
/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */
async function m01Value(group: CfGroup, e: CfElementRef): Promise<number | null> {
if (e.ref.startsWith(USER_PREFIX)) return null;
try {
const [key, field] = e.ref.split(MACHINE_SEP);
const page = await fetchRows(
await fileOf(group),
1,
30,
e.name.split(" · ")[0].split(" ")[0],
false,
"",
"",
);
const row = (page.rows as M01Row[]).find((r) => r["키"] === key);
return row ? valueOf(group, row, field || "가격") : null;
} catch {
return null;
}
}
/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */
async function drawFind(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): Promise<void> {
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
const count = el("span", { className: "m02-dfip__muted" });
const bar: HTMLElement[] = [];
let file = "";
let sub = "";
let detail = "";
let subs: SubInfo[] = [];
const all = { value: "", text: t("AllSub") };
const subBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("SubLabel"),
onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()),
});
const detailBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("DetailLabel"),
onChange: (v) => ((detail = v), void load()),
});
const fillDetail = (): void => {
const d = subs.find((s) => s.name === sub)?.details ?? [];
detailBox.root.hidden = !d.length;
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], "");
};
fillDetail();
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
try {
const word = q.input.value.trim();
const [page, users] = await Promise.all([
fetchRows(file, 1, 40, word, false, sub, detail),
apiUsers().catch((): UserEl[] => []),
]);
if (mine !== seq) return;
const known = new Map<string, UserEl>();
for (const u of [...lg.userElements, ...users])
if (u.group === group) known.set(u.id, u as UserEl);
const mineRows: M01Row[] = [...known.values()]
.filter((u) => !word || `${u.name} ${u.spec}`.includes(word))
.map((u) => ({
키: u.id,
이름: u.name,
규격: u.spec,
단위: u.unit,
값: u.value,
__mine: true,
}));
const rows = [...mineRows, ...(page.rows as M01Row[])];
count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length });
list.replaceChildren(
...(rows.length
? rows.map((r) => rowOf(r))
: [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]),
);
} catch (e) {
if (mine === seq)
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) }));
}
};
const rowOf = (r: M01Row): HTMLElement => {
const fields = group === "기계" ? machineFields(r) : [];
const field = createSelectField({
options: fields.map((f) => ({ value: f, text: f })),
value: fields.includes("가격") ? "가격" : (fields[0] ?? ""),
compact: true,
ariaLabel: t("Field"),
});
const pick = (): CfLogic =>
addElement(lg, box.id, elementOf(group, r, field.select.value || "가격"));
const v = valueOf(group, r, field.select.value || "가격");
const unitText = group === "기계" ? "" : String(r["단위"] ?? "");
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: String(r["이름"] ?? "") }),
el("span", { className: "m02-dfip__muted", text: ` ${r["규격"] ?? ""}` }),
...(r["__mine"] ? [el("span", { className: "m02-dfip__tag", text: t("Mine") })] : []),
],
}),
el("span", {
className: "m02-dfip__muted",
text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(),
}),
],
});
b.addEventListener("click", () => on.change(pick()));
return el("div", {
className: "m02-dfip__rowbox",
children: [b, ...(fields.length ? [field.root] : [])],
});
};
try {
file = await fileOf(group);
subs = await fetchSubs({ file }).catch(() => []);
subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], "");
subBox.root.hidden = !subs.length;
bar.push(subBox.root, detailBox.root);
} catch (e) {
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) }));
return;
}
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
host.replaceChildren(
el("div", { className: "m02-dfip__bar", children: [...bar, q.root] }),
count,
list,
);
void load();
void USER_PREFIX;
}
/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */
function newForm(
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): HTMLElement {
const name = createInputField({ type: "text", label: t("NewName"), compact: true });
const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true });
const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true });
const value = createInputField({ type: "text", label: t("NewValue"), compact: true });
const go = createButton({
label: t("Make"),
onClick: () => {
const n =
value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, ""));
if (!name.input.value.trim()) return name.setError(t("NeedName"));
if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue"));
name.setError(undefined);
value.setError(undefined);
apiAdd({
group,
name: name.input.value.trim(),
spec: spec.input.value.trim(),
unit: unit.input.value.trim(),
value: n,
}).then(
(made) => {
const el1 = elementOf(group, {
키: made.id,
이름: made.name,
규격: made.spec,
단위: made.unit,
값: made.value,
});
const withUser: CfLogic = {
...lg,
userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }],
};
on.change(addElement(withUser, box.id, el1));
},
(e) => showToast(plain(e), "error"),
);
},
});
const d = el("details", {
className: "m02-dfip__new",
children: [
el("summary", { text: t("NewHead") }),
name.root,
spec.root,
unit.root,
value.root,
go,
],
});
return d;
}
@@ -0,0 +1,294 @@
/* =============================================================================
* M02_DF_InputPanel_Build.ts
* 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic)
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts`
* ========================================================================== */
import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_DF_Model";
/** 입력 상자가 고르는 그룹 — 화면 차례 */
export const GROUPS: CfGroup[] = [
"인력",
"재료",
"유가전력",
"기계",
"소요량",
"계수",
"환율",
"요율",
];
/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */
export const M01_OF: Record<CfGroup, { group: string; part: string }> = {
인력: { group: "인력", part: "" },
재료: { group: "재료", part: "자재품목" },
유가전력: { group: "재료", part: "유가전력" },
기계: { group: "기계", part: "" },
소요량: { group: "소요량", part: "" },
계수: { group: "계수", part: "" },
환율: { group: "환율", part: "" },
요율: { group: "요율", part: "" },
};
/** M01 줄 한 개 — 파일 그대로(한글 칸) */
export type M01Row = Record<string, unknown>;
/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */
export const USER_PREFIX = "UE-";
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */
export const MACHINE_SEP = "#";
export const machineFields = (row: M01Row): string[] =>
row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : [];
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const dictUnit = (row: M01Row, field: string): string =>
row["단위"] && typeof row["단위"] === "object"
? text((row["단위"] as Record<string, unknown>)[field])
: "";
/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */
export function lowest(row: M01Row): number | null {
const found = ["물가자료", "유통물가", "물가정보", "거래가격"]
.map((k) => row[k])
.filter((v): v is number => typeof v === "number");
return found.length ? Math.min(...found) : null;
}
/** 줄 옆에 보일 값 — 모르면 null */
export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null {
if (group === "재료") return lowest(row);
if (group === "기계")
return typeof (row["값"] as Record<string, unknown> | undefined)?.[field] === "number"
? (row["값"] as Record<string, number>)[field]
: null;
if (group === "요율") {
const v = row["값"] as Record<string, unknown> | undefined;
return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null;
}
return typeof row["값"] === "number" ? row["값"] : null;
}
/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */
export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef {
const key = text(row["키"]);
const spec = text(row["규격"]);
if (key.startsWith(USER_PREFIX))
return {
ref: key,
name: [text(row["이름"]), spec].filter(Boolean).join(" "),
spec,
unit: text(row["단위"]) || "-",
};
// 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름
const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec;
const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게)
if (group === "기계")
return {
ref: `${key}${MACHINE_SEP}${field}`,
name: `${named} · ${field}`,
spec,
unit: dictUnit(row, field) || "-",
};
if (group === "요율")
return {
ref: key,
name: text(row["이름"]),
spec: "",
unit: valueOf(group, row) === null ? "-" : "%",
};
return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" };
}
/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */
export function pillNames(box: CfBox): string[] {
const i = box.input;
if (!i) return [];
if (i.table) return ["표"];
return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name);
}
/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */
export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
if (group === "기계" && !ref.startsWith(USER_PREFIX)) {
const [key, field] = ref.split(MACHINE_SEP);
return { group, ref: key, field: field || "가격" };
}
return { group, ref };
}
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
export function unitOfInput(input: CfInput): string {
if (input.table) return "-";
const units = (
input.mode === "variable"
? input.variables.map((v) => v.unit)
: input.elements.map((e) => e.unit)
).map((u) => u || "-");
return units.length && units.every((u) => u === units[0]) ? units[0] : "-";
}
/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkVariable(v: CfVariable, others: CfVariable[]): string | null {
const name = v.name.trim();
if (!name) return "이름을 적어 주세요";
if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음";
if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상";
if (v.type === "number") {
if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함";
if (
typeof v.example === "number" &&
v.range &&
(v.example < v.range[0] || v.example > v.range[1])
)
return "예시값이 범위 밖";
if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자";
}
if (
v.type === "choice" &&
v.example !== null &&
!(v.choices ?? []).map(String).includes(String(v.example))
)
return "예시값이 선택 목록에 없음";
return null;
}
/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */
type Pills = (box: CfBox) => string[];
const defaultPills: Pills = pillNames;
const touch = (
lg: CfLogic,
id: string,
fn: (b: CfBox) => CfBox,
pills: Pills = defaultPills,
): CfLogic => {
const old = lg.boxes.find((b) => b.id === id);
if (!old) return lg;
const next = fn(old);
const boxes = lg.boxes.map((b) =>
b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b,
);
// 알약이 없어진 선은 지움
const keep = new Set(pills(next));
const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name));
return { ...lg, boxes, links };
};
const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] };
/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */
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: "variable", elements: [], variables: cur.variables } };
const same = cur.mode === "group" && cur.group === kind;
return {
...b,
input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] },
};
},
pills,
);
}
/** 요소 넣기 — 같은 요소는 한 번만 */
export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
return cur.elements.some((e) => e.ref === el.ref || e.name === el.name)
? b
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
},
pills,
);
}
export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) },
}),
pills,
);
}
/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */
export function setTable(
lg: CfLogic,
id: string,
table: { key: string; row?: number } | null,
name?: string,
pills?: Pills,
): CfLogic {
const next = touch(
lg,
id,
(b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }),
pills,
);
return name
? {
...next,
boxes: next.boxes.map((b) =>
b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b,
),
}
: next;
}
/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */
export function putVariable(
lg: CfLogic,
id: string,
v: CfVariable,
oldName?: string,
pills?: Pills,
): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
const vars =
oldName === undefined
? [...cur.variables, v]
: cur.variables.map((x) => (x.name === oldName ? v : x));
return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } };
},
pills,
);
}
export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) },
}),
pills,
);
}
/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */
export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
const box = lg.boxes.find((b) => b.id === id);
if (!box?.input) return lg;
const want = unitOfInput(box.input);
return box.unit === want
? lg
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
}
@@ -0,0 +1,323 @@
/* =============================================================================
* M02_DF_InputPanel_Table.ts
* 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나)
* `M02_DF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래
* ========================================================================== */
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 type { CfGroup, CfLogic } from "./M02_DF_Model";
import { setTable } from "./M02_DF_InputPanel_Build";
const TEXT = {
Book: ["적용 품셈", "Standard"],
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
Loading: ["찾는 중…", "Searching…"],
NoSection: ["찾는 절 없음", "No section"],
SecLine: ["표 {t} 개", "{t} tables"],
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"],
TableLine: [
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
"{b} · {c} conditions · {v} results · {n} rows",
],
Chosen: ["고른 표", "Chosen table"],
Whole: [
"표 통째 — 함수 상자가 조건에 맞는 줄을 찾음",
"Whole table — a function box looks it up",
],
OneRow: ["줄 하나 고정", "Fix one row"],
RowNo: ["몇 번째 줄", "Row number"],
Preview: ["미리 보기", "Preview"],
Notes: ["주(註)", "Notes"],
Remove: ["표 빼기", "Remove table"],
Change: ["다른 표 고르기", "Pick another table"],
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,
);
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
credentials: "include",
});
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!res.ok) throw new Error(String(data.detail ?? res.statusText));
return data;
}
interface SectionItem {
section: string;
title: string;
chapter: string;
tables: number;
}
interface TableHead {
file: string;
키: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
count: number;
}
interface RawTable {
키: string;
원문번호: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
줄: Record<string, unknown>[];
주?: string[];
}
const BOOKS = ["산림품셈", "건설품셈"];
const files = new Map<string, string>();
const tables = new Map<string, RawTable>();
/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */
export async function loadTable(group: CfGroup, key: string): Promise<RawTable> {
const hit = tables.get(key);
if (hit) return hit;
let file = files.get(key);
if (!file) {
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
(x) => x.키 === key,
);
if (!found) throw new Error(t("Failed"));
file = found.file;
}
const raw = (await get<{ table: RawTable }>("/table", { file, key })).table;
tables.set(key, raw);
return raw;
}
const cell = (v: unknown): string =>
Array.isArray(v)
? v.map((x) => x ?? "").join("~")
: v === null || v === undefined
? ""
: String(v);
/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */
function previewOf(tb: RawTable, row?: number): HTMLElement {
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1));
return el("div", {
className: "m02-dfip__preview",
children: [
el("table", {
children: [
el("tr", { children: cols.map((c) => el("th", { text: c })) }),
...body.map((r, i) =>
el("tr", {
className: i === row ? "is-hit" : "",
children: cols.map((c) => el("td", { text: cell(r[c]) })),
}),
),
],
}),
...(tb.줄.length > body.length
? [el("p", { className: "m02-dfip__muted", text: `… ${tb.줄.length} 줄` })]
: []),
...(tb.주?.length
? [
el("details", {
children: [
el("summary", { text: t("Notes") }),
...tb.주.map((n) => el("p", { text: n })),
],
}),
]
: []),
],
});
}
/** 소요량 · 계수 표 고르기 화면 */
export function renderTablePick(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const cur = box?.input?.table;
if (cur) return void showChosen(host, lg, boxId, group, cur, on);
let book = BOOKS[0];
const bookBox = createSelectField({
label: t("Book"),
options: BOOKS.map((b) => ({ value: b, text: b })),
value: book,
compact: true,
onChange: (v) => ((book = v), load()),
});
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
const tableList = el("div", { className: "m02-dfip__list" });
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
try {
const r = await get<{ sections: SectionItem[] }>("/sections", {
book,
q: q.input.value.trim(),
limit: "60",
});
if (mine !== seq) return;
list.replaceChildren(
...(r.sections.length
? r.sections.map((s) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "m02-dfip__muted",
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "m02-dfip__muted", text: t("NoSection") })]),
);
} catch (e) {
if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error");
}
};
const pickSection = async (s: SectionItem): Promise<void> => {
try {
const heads = (
await get<{ tables: TableHead[] }>("/tables", {
group,
section: s.section,
sub: book,
size: "100",
})
).tables;
tableList.replaceChildren(
el("h5", { text: t("Tables") }),
...(heads.length
? heads.map((h) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "m02-dfip__muted",
text: t("TableLine", {
b: h.기준 || "—",
c: Object.keys(h.조건).length,
v: Object.keys(h.값칸).length,
n: h.count,
}),
}),
],
});
b.addEventListener("click", () => {
files.set(h.키, h.file);
void loadTable(group, h.키).then(
(tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)),
(e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"),
);
});
return b;
})
: [el("p", { className: "m02-dfip__muted", text: t("NoTable") })]),
);
} catch (e) {
showToast(e instanceof Error ? e.message : t("Failed"), "error");
}
};
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
host.replaceChildren(bookBox.root, q.root, list, tableList);
void load();
}
/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
cur: { key: string; row?: number },
on: { change(lg: CfLogic): void },
): void {
const body = el("div", { className: "m02-dfip__stack" });
host.replaceChildren(body);
void loadTable(group, cur.key).then(
(tb) => {
const whole = cur.row === undefined;
const modeWhole = createButton({
label: t("Whole"),
variant: whole ? "filled" : "ghost",
onClick: () => on.change(setTable(lg, boxId, { key: cur.key })),
});
const modeOne = createButton({
label: t("OneRow"),
variant: whole ? "ghost" : "filled",
onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })),
});
const rowBox = createSelectField({
label: t("RowNo"),
options: tb.줄.map((r, i) => ({
value: String(i),
text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)]
.slice(0, 4)
.map((c) => cell(r[c]))
.join(" / ")}`,
})),
value: String(cur.row ?? 0),
compact: true,
onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })),
});
body.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }),
el("div", { className: "m02-dfip__chips", children: [modeWhole, modeOne] }),
...(whole ? [] : [rowBox.root]),
el("h5", { text: t("Preview") }),
previewOf(tb, cur.row),
el("div", {
className: "m02-dfip__chips",
children: [
createButton({
label: t("Change"),
variant: "ghost",
onClick: () => on.change(setTable(lg, boxId, null)),
}),
],
}),
);
},
(e) =>
body.replaceChildren(
el("p", {
className: "m02-dfip__muted",
text: e instanceof Error ? e.message : t("Failed"),
}),
),
);
}
@@ -0,0 +1,201 @@
/* =============================================================================
* M02_DF_InputPanel_Vars.ts
* 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값
* 변수마다 출력 알약 하나 · `M02_DF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { CfLogic, CfVariable } from "./M02_DF_Model";
import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build";
const TEXT = {
List: ["변수 — 알약마다 하나", "Variables — one pill each"],
None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"],
Name: ["이름", "Name"],
Type: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
Unit: ["단위", "Unit"],
Min: ["범위 아래", "Range from"],
Max: ["범위 위", "Range to"],
Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"],
Example: ["예시값", "Example"],
Add: ["변수 넣기", "Add variable"],
Save: ["고치기 끝", "Save change"],
Edit: ["고치기", "Edit"],
Remove: ["삭제", "Remove"],
Cancel: ["취소", "Cancel"],
Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"],
Range: ["범위 {a}~{b}", "range {a}~{b}"],
} 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 numOf = (s: string): number | null =>
s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null;
/** 변수 한 줄 글 */
export function variableLine(v: CfVariable): string {
const base = t("Line", {
n: v.name,
u: v.unit || "-",
e: v.example === null ? "—" : String(v.example),
});
const range =
v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : "";
const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : "";
return `${base}${range}${choices}`;
}
export function renderVariables(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const vars = box?.input?.variables ?? [];
let editing: CfVariable | null = null;
const form = (): HTMLElement => {
const cur = editing;
const name = createInputField({
type: "text",
label: t("Name"),
value: cur?.name ?? "",
placeholder: "예: 면적",
compact: true,
});
const type = createSelectField({
label: t("Type"),
options: [
{ value: "number", text: t("Number") },
{ value: "choice", text: t("Choice") },
{ value: "text", text: t("Text") },
],
value: cur?.type ?? "number",
compact: true,
onChange: () => paint(),
});
const unit = createInputField({
type: "text",
label: t("Unit"),
value: cur?.unit ?? "",
placeholder: "예: ㎡",
compact: true,
});
const lo = createInputField({
type: "text",
label: t("Min"),
value: cur?.range ? String(cur.range[0]) : "",
compact: true,
});
const hi = createInputField({
type: "text",
label: t("Max"),
value: cur?.range ? String(cur.range[1]) : "",
compact: true,
});
const choices = createInputField({
type: "text",
label: t("Choices"),
value: (cur?.choices ?? []).join(", "),
compact: true,
});
const example = createInputField({
type: "text",
label: t("Example"),
value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example),
compact: true,
});
const extra = el("div", { className: "m02-dfip__stack" });
const paint = (): void => {
const ty = type.select.value;
extra.replaceChildren(
...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []),
);
};
paint();
const submit = (): void => {
const ty = type.select.value as CfVariable["type"];
const ex = example.input.value.trim();
const v: CfVariable = {
name: name.input.value.trim(),
type: ty,
unit: unit.input.value.trim(),
example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex,
...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null
? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] }
: {}),
...(ty === "choice"
? {
choices: choices.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}
: {}),
};
const why = checkVariable(
v,
vars.filter((x) => x.name !== cur?.name),
);
name.setError(why ?? undefined);
if (why) return;
on.change(putVariable(lg, boxId, v, cur?.name));
};
const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit });
const cancel = cur
? createButton({
label: t("Cancel"),
variant: "ghost",
onClick: () => ((editing = null), draw()),
})
: null;
return el("div", {
className: "m02-dfip__form",
children: [
name.root,
type.root,
unit.root,
extra,
example.root,
el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }),
],
});
};
const draw = (): void => {
const list = vars.length
? vars.map((v) =>
el("div", {
className: "m02-dfip__item",
children: [
el("span", { text: variableLine(v) }),
createButton({
label: t("Edit"),
variant: "ghost",
size: "sm",
onClick: () => ((editing = v), draw()),
}),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeVariable(lg, boxId, v.name)),
}),
],
}),
)
: [el("p", { className: "m02-dfip__muted", text: t("None") })];
host.replaceChildren(el("h5", { text: t("List") }), ...list, form());
};
draw();
}
+329
View File
@@ -0,0 +1,329 @@
/* =============================================================================
* M02_DF_Model.ts
* 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
export type CfKind = "input" | "function" | "output";
export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율";
export type CfCost = "노무비" | "재료비" | "경비" | "값";
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */
export interface CfElementRef {
ref: string;
name: string;
spec: string;
unit: string;
}
/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */
export interface CfVariable {
name: string;
type: "number" | "choice" | "text";
unit: string;
choices?: (string | number)[];
range?: [number, number];
example: number | string | null;
/** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */
cell?: string;
}
export interface CfInput {
mode: "group" | "variable";
group?: CfGroup;
elements: CfElementRef[];
/** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */
table?: { key: string; row?: number };
variables: CfVariable[];
}
export interface CfFunc {
/** 우리 스프레드시트 시트 하나 */
sheet: Sheet;
/** 받는 알약 → 칸 */
inputs: { port: string; cell: string }[];
variables: CfVariable[];
/** 내보내는 알약 하나 */
output: { cell: string; name: string; unit: string };
}
export interface CfOutput {
cost: CfCost;
unit: string;
}
export interface CfBox {
id: string;
kind: CfKind;
name: string;
color: string;
/** 오른쪽 위 단위 — 기본 「-」 */
unit: string;
x: number;
y: number;
input?: CfInput;
func?: CfFunc;
output?: CfOutput;
}
/** 알약 한쪽 끝 */
export interface CfPort {
box: string;
name: string;
}
export interface CfLink {
id: string;
from: CfPort;
to: CfPort;
}
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */
export interface CfUserElement {
id: string;
group: CfGroup;
name: string;
spec: string;
unit: string;
value: number | null;
}
export interface CfLogic {
key: string | null;
name: string;
resultUnit: string;
boxes: CfBox[];
links: CfLink[];
userElements: CfUserElement[];
}
/** 알약 하나의 모습 */
export interface CfPill {
name: string;
unit: string;
}
const TEXT = {
Input: ["입력", "Input"],
Func: ["함수", "Function"],
Output: ["출력", "Output"],
Result: ["결과", "Result"],
Value: ["값", "Value"],
NoPill: ["알약이 없음", "No such pill"],
FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"],
ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"],
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 종류별 기본 색 */
export const BOX_COLOR: Record<CfKind, string> = {
input: "#2f80ed",
function: "#7b4fd6",
output: "#27ae60",
};
/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */
export const OUTPUT_PORT = "값";
export const TABLE_PORT = "표";
const clone = <T>(v: T): T => structuredClone(v);
/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */
function nextId(prefix: string, ids: string[]): string {
const nums = ids
.filter((x) => x.startsWith(prefix))
.map((x) => Number(x.slice(prefix.length)))
.filter((n) => Number.isInteger(n));
return `${prefix}${Math.max(0, ...nums) + 1}`;
}
/** 같은 이름이면 「 2」 「 3」 */
function uniqueName(used: string[], base: string): string {
if (!used.includes(base)) return base;
for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`;
}
const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */
export function updateBox(
lg: CfLogic,
id: string,
patch: Partial<Omit<CfBox, "id" | "kind">>,
): CfLogic {
return {
...lg,
boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)),
};
}
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic {
return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] };
}
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */
export function addBox(
lg: CfLogic,
kind: CfKind,
x: number,
y: number,
): { lg: CfLogic; box: CfBox } {
const id = nextId(
"b",
lg.boxes.map((b) => b.id),
);
const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output");
const name = uniqueName(
lg.boxes.map((b) => b.name),
base,
);
const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y };
if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] };
if (kind === "function") {
// 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음
const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : []));
let n = 1;
while (taken.includes(`${tl("Result")} ${n}`)) n++;
const out = `${tl("Result")} ${n}`;
box.func = {
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } },
inputs: [],
variables: [],
output: { cell: "B1", name: out, unit: "-" },
};
}
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
}
/** 상자 지우기 — 이어진 선도 지움 */
export function removeBox(lg: CfLogic, id: string): CfLogic {
// 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리
let next = lg;
for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id);
return { ...next, boxes: next.boxes.filter((b) => b.id !== id) };
}
export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic {
return updateBox(lg, id, { x, y });
}
export function setColor(lg: CfLogic, id: string, color: string): CfLogic {
return updateBox(lg, id, { color });
}
/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */
export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
const box = boxOf(lg, id);
if (!box) return lg;
const u = unit.trim() || "-";
const patch: Partial<CfBox> = { unit: u };
if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } };
if (box.output) patch.output = { ...box.output, unit: u };
return updateBox(lg, id, patch);
}
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name
* 함수 = func.output.name 하나 · 출력 = 없음 */
export function portsOut(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
if (box.kind !== "input" || !box.input) return [];
const inp = box.input;
return [
...(inp.mode === "group"
? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" }))
: []),
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
];
}
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */
export function portsIn(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
return [];
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** from 상자에서 선을 따라 to 상자에 닿나 */
export function reaches(lg: CfLogic, from: string, to: string): boolean {
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (id === to) return true;
if (seen.has(id)) return false;
seen.add(id);
return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box));
};
return walk(from);
}
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
const a = boxOf(lg, from.box);
const b = boxOf(lg, to.box);
if (!a || !b) return { lg, why: tl("NoPill") };
if (a.kind === "output") return { lg, why: tl("FromOutput") };
if (b.kind === "input") return { lg, why: tl("ToInput") };
if (a.id === b.id) return { lg, why: tl("Self") };
if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") };
const has = portsIn(b).some((p) => p.name === to.name);
if (!has && b.kind !== "function") return { lg, why: tl("NoPill") };
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
return { lg, why: tl("Taken") };
let next = lg;
let port = to.name;
if (!has) {
const used = portsIn(b).map((p) => p.name);
const base = to.name.trim() || from.name;
port = used.includes(base) ? uniqueSuffix(used, base) : base;
const func = b.func!;
next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } });
}
const id = nextId(
"l",
next.links.map((l) => l.id),
);
return {
lg: {
...next,
links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }],
},
why: null,
};
}
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
function uniqueSuffix(used: string[], base: string): string {
for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`;
}
/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */
export function unlink(lg: CfLogic, id: string): CfLogic {
const gone = lg.links.find((l) => l.id === id);
const next = { ...lg, links: lg.links.filter((l) => l.id !== id) };
const box = gone && boxOf(next, gone.to.box);
if (!gone || !box?.func) return next;
const port = box.func.inputs.find((i) => i.port === gone.to.name);
const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name);
if (!port || port.cell.trim() || still) return next;
const inputs = box.func.inputs.filter((i) => i !== port);
return updateBox(next, box.id, { func: { ...box.func, inputs } });
}
@@ -0,0 +1,85 @@
.m02-dfout__title {
color: var(--color-text-body);
font-size: var(--text-body);
}
.m02-dfout__body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-16);
margin: var(--spacing-8) 0;
}
.m02-dfout__got {
min-width: 220px;
color: var(--color-text-body);
}
.m02-dfout__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);
}
.m02-dfout__got dt {
color: var(--color-text-secondary);
}
.m02-dfout__got dd {
margin: 0;
}
.m02-dfout__num {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m02-dfout__fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16);
}
.m02-dfout__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-dfout__input {
box-sizing: border-box;
width: 160px;
max-width: 100%;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m02-dfout__warn {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m02-dfout__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m02-dfout__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
@@ -0,0 +1,195 @@
/* =============================================================================
* M02_DF_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_DF_Eval";
import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_DF_Model";
import "./M02_DF_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: "m02-dfout__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: "m02-dfout__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: "m02-dfout__num",
text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`,
}),
],
}),
);
} else {
got.replaceChildren(
el("strong", { text: to("Got") }),
el("p", { className: "m02-dfout__warn", text: to("NoLink") }),
);
}
/* 이름 · 비용 종류 · 단위 */
const nameBox = el("input", {
className: "m02-dfout__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: "m02-dfout__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: "m02-dfout__field",
children: [el("span", { text: label }), control],
});
host.replaceChildren(
el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }),
el("div", {
className: "m02-dfout__body",
children: [
got,
el("div", {
className: "m02-dfout__fields",
children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)],
}),
],
}),
...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []),
msg,
);
}
@@ -0,0 +1,664 @@
"""단가산출 다이어그램 시험 길(PLAN 35-7) — 목록 · 열기 · 저장 · 지우기 · 입력 값 · 사용자 요소.
GET /api/m02/df/list → {logics: [{key, name, resultUnit, saved, sample}]}
GET /api/m02/df/get?key=DF-0001 → {logic, sample}
POST /api/m02/df/save {logic} → {key, logic, values: {ports, outputs, cost, errors}}
POST /api/m02/df/delete {key}
POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]}
GET /api/m02/df/user-elements → {elements}
POST /api/m02/df/user-elements {op: add | delete, element? | id?}
모양 = sub7 Model `M02_DF_Model.ts`
(CfLogic = key · name · resultUnit · boxes · links · userElements).
알약 이름(브레인 정함): 입력 상자가 내보내는 알약 = 요소 name · 표 「표」 · 변수 name /
함수 상자 받는 알약 = inputs[].port · 내보내는 알약 = output.name / 출력 상자 받는 알약 = 「값」.
저장 = 입력 값(노임 · 재료 · 유가전력 · 기계 · 환율 · 요율 · 소요량 / 계수 표 · 사용자 요소)을
마스터(사용자 요소는 다이어그램 안 · `_data`)에서 다시 받아 → 함수 상자 시트들을 한 통합문서로
묶어(받는 칸에 값 · 함수 → 함수는 시트 사이 참조) 우리 스프레드시트 엔진(Node ·
`common_util_spreadsheet`)으로 셈 → 받는 칸에 값만 되돌려 넣어 `_data` 에 씀 · 브라우저 값은
안 받아 적음. 단가는 기존 `/calc` 와 같은 셈.
자리 = 이 폴더 `_data/`(git 밖) · 견본 `_sample/`(git 안 · 읽기만 · 저장하면 새 번호) ·
키 `DF-0001` · 사용자 요소 `UE-0001` · 마스터 파일은 안 씀. 권한 = 시스템 관리자(`main.py` 한 줄).
옛 시험판(SheetLogic) 서버를 옮겨 고쳐 씀 — 그 폴더를 import 하지 않음.
정함 = `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`.
"""
from __future__ import annotations
import copy
import json
import os
import re
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from common_util import common_util_spreadsheet as spreadsheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.M01_MasterData_Router import _num
cm = store.cm
router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow (시험)"])
HERE = Path(__file__).resolve().parent
DATA = HERE / "_data" # 시험은 임시 폴더로 바꿈
SAMPLE = HERE / "_sample"
USERS = "_user_elements.json" # 사용자 요소 공용 목록 — `_data` 안
_KEY = re.compile(r"^DF-(\d{4,})$")
_UKEY = re.compile(r"^UE-(\d{4,})$")
_SAMPLE_KEY = re.compile(r"^[\w가-힣·() -]+$")
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
RANGE_LOW, RANGE_HIGH = "·아래", "·위"
FIELDS = (*cm.mf.COST_ITEMS, "계")
GROUPS = ("인력", "재료", "유가전력", "기계", "소요량", "계수", "환율", "요율")
TABLE_PORT, OUTPUT_PORT = "표", "값"
COSTS = ("노무비", "재료비", "경비")
class LogicBody(BaseModel):
logic: dict[str, Any]
class KeyBody(BaseModel):
key: str
class ValuesBody(BaseModel):
items: list[dict[str, Any]]
userElements: list[dict[str, Any]] = []
class UserBody(BaseModel):
op: str
element: dict[str, Any] | None = None
id: str | None = None
# ── 자리 ──────────────────────────────────────────────────────────────
def _write(path: Path, data: Any) -> None:
DATA.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
os.replace(tmp, path)
def _mine(key: str) -> Path:
if not _KEY.match(key or ""):
raise store.StoreError(400, f"다이어그램 번호 모양이 아님 「{key}」")
return DATA / f"{key}.json"
def _sample(key: str) -> Path | None:
if not key or _KEY.match(key) or not _SAMPLE_KEY.match(key):
return None
path = SAMPLE / f"{key}.json"
return path if path.is_file() else None
def _read(key: str) -> tuple[dict, bool]:
"""(다이어그램, 견본인가) — 내 것은 `_data` · 견본은 `_sample`."""
path = _sample(key)
if path is not None:
return json.loads(path.read_text(encoding="utf-8")), True
path = _mine(key)
if not path.is_file():
raise store.StoreError(404, f"없는 다이어그램 「{key}」")
return json.loads(path.read_text(encoding="utf-8")), False
def listing() -> dict:
out = []
for path, sample in [
*((p, False) for p in sorted(DATA.glob("DF-*.json"))),
*((p, True) for p in sorted(SAMPLE.glob("*.json"))),
]:
logic = json.loads(path.read_text(encoding="utf-8"))
out.append(
{
"key": path.stem,
"name": logic.get("name") or path.stem,
"resultUnit": logic.get("resultUnit") or "",
"saved": logic.get("saved") or "",
"sample": sample,
}
)
return {"logics": out}
def get(key: str) -> dict:
logic, sample = _read(key)
return {"logic": {**logic, "key": key}, "sample": sample}
def delete(key: str) -> dict:
if _sample(key) is not None:
raise store.StoreError(403, "견본은 지울 수 없음")
_read(key)
_mine(key).unlink()
return {"key": key}
# ── 사용자 요소(Model CfUserElement — id · group · name · spec · unit · value) ─────
def _users() -> list[dict]:
path = DATA / USERS
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else []
def user_elements() -> dict:
return {"elements": _users()}
def user_edit(op: str, element: dict | None, uid: str | None) -> dict:
rows = _users()
if op == "delete":
if not any(r["id"] == uid for r in rows):
raise store.StoreError(404, f"없는 사용자 요소 「{uid}」")
_write(DATA / USERS, [r for r in rows if r["id"] != uid])
return {"id": uid}
if op != "add" or not isinstance(element, dict):
raise store.StoreError(400, "op 는 add(element) 또는 delete(id)")
name = str(element.get("name") or "").strip()
if not name:
raise store.StoreError(400, "이름을 넣어 주세요")
group = element.get("group") or "재료"
if group not in GROUPS:
raise store.StoreError(400, f"모르는 그룹 「{group}」")
value = element.get("value")
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))):
raise store.StoreError(400, "값은 수 또는 빈 값")
used = [int(m.group(1)) for r in rows if (m := _UKEY.match(r["id"]))]
one = {
"id": f"UE-{max(used, default=0) + 1:04d}",
"group": group,
"name": name,
"spec": str(element.get("spec") or ""),
"unit": str(element.get("unit") or ""),
"value": value,
}
_write(DATA / USERS, [*rows, one])
return {"element": one}
# ── 입력 값 ────────────────────────────────────────────────────────────
def _row(line: dict, names) -> dict:
"""단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직."""
return {
"키": "",
"원문번호": "",
"이름": "입력 값",
"결과단위": "원/식",
"출처": "자체",
"입력": [{"이름": n} for n in names],
"중간": [],
"호표": [line],
"덧줄": [],
"끝수": None,
}
def _money(got: dict, field: str | None) -> dict:
if not got["ok"]:
return got
if "result" in got:
return {"ok": True, "value": got["result"]}
pick = field or "계"
if pick not in FIELDS:
return {"ok": False, "reason": f"모르는 칸 「{pick}」"}
return {"ok": True, "value": got["sums"][pick]}
def _pick(key: Any) -> dict | None:
"""재료 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None."""
if isinstance(key, dict):
return key
if isinstance(key, str) and key.lstrip().startswith("{"):
try:
got = json.loads(key)
except ValueError:
return None
return got if isinstance(got, dict) else None
return None
def _material(cond: dict, unit: str) -> dict:
"""검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산)."""
line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"}
got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
if one["단가"] is None:
word = cond.get("검색어") or ""
empty = "값 없음" in str(one.get("까닭") or "")
why = (
"단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름"
if empty
else "맞는 재료 없음"
)
return {"ok": False, "reason": f"「{word}」 {why}"}
picked = one.get("품목") or {}
return {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None}
def _brief(key: str, want: str) -> tuple[dict | None, str | None]:
"""마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키."""
try:
row = store.loaded()[1].get(key)
except cm.mf.FormulaError as e:
return None, str(e)
group = store.mk.GROUP_OF.get(store.mk.id_of(key))
if not (group == want or (want == "유가전력" and key.startswith("MO"))):
return None, f"「{key}」 는 {want} 아님"
return row, None
def _price(group: str, key: str, unit: str = "") -> dict:
"""인력 노임 · 재료 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
row, why = _brief(key, group)
if row is None:
return {"ok": False, "reason": why}
kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group]
line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"}
line = {**line, "단위": unit or row.get("단위") or "", "비목": cost}
got = store.calc("", {}, _row(line, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
out = {"ok": one["단가"] is not None, "value": one["단가"], "name": row.get("이름")}
return {**out, "unit": row.get("단위"), **({"reason": one["까닭"]} if one.get("까닭") else {})}
def _plain_value(group: str, key: str, field: str | None) -> dict:
"""유가전력 · 환율 · 요율 · 기계 값 칸 — 그 줄 값(묶음이면 field · 요율 기본 rate_percent)."""
row, why = _brief(key, group)
if row is None:
return {"ok": False, "reason": why}
value, unit = row.get("값"), row.get("단위")
if isinstance(value, dict):
pick = field or ("rate_percent" if group == "요율" else "")
if pick not in value:
return {
"ok": False,
"reason": f"「{row.get('이름')}」 에서 쓸 칸을 고름 — {', '.join(value)}",
}
value = value[pick]
unit = (
unit.get(pick) if isinstance(unit, dict) else ("%" if pick == "rate_percent" else unit)
)
if value is None:
return {"ok": False, "reason": f"「{row.get('이름')}」 값이 자료에 비어 있음"}
return {"ok": True, "value": value, "name": row.get("이름"), "unit": unit}
def _table(key: str, at: Any = None) -> dict:
"""소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 ·
그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나."""
files = store.loaded()[0]
table = next(
(
t
for d in files.values()
if d.get("그룹") in cm.mf.TABLE_GROUPS
for t in d.get("표") or []
if str(t.get("키")) == key
),
None,
)
if table is None:
return {"ok": False, "reason": f"없는 표 「{key}」"}
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 ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))]
if at is not None:
if not isinstance(at, int) or not 0 <= at < len(lines):
return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"}
lines = [lines[at]]
rows = []
for r in lines:
line = []
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(None if v == "" else v if not isinstance(v, dict) else str(v))
rows.append(line)
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
def _user(uid: str, local: list[dict]) -> dict:
one = next((r for r in [*local, *_users()] if r.get("id") == uid), None)
if one is None:
return {"ok": False, "reason": f"없는 사용자 요소 「{uid}」"}
if one.get("value") is None:
return {"ok": False, "reason": f"「{one.get('name')}」 값이 비어 있음"}
return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")}
MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」
HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝)
_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
def _machine_family() -> list[dict]:
"""기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직."""
files = store.loaded()[0]
return [
row
for name, data in files.items()
if data.get("그룹") == "로직" and "건설기계" in name
for row in data.get("줄") or []
if str(row.get("결과단위") or "").startswith("원")
and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or [])
]
def _machine_rank(row: dict) -> tuple:
"""짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키."""
names = [i.get("이름") for i in row.get("입력") or []]
codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계")
attach = any(n not in _MACHINE_PLAIN for n in names)
loss = "지역" not in names
return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키")))
def _machine_hourly(key: str, inputs: dict) -> dict:
"""기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기."""
row, why = _brief(key, "기계")
if row is None:
return {"ok": False, "reason": why}
code = str(row.get("원문번호") or "")
fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]]
if not fit:
return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"}
logic = min(fit, key=_machine_rank)
given = {}
for spec in logic.get("입력") or []:
name = spec.get("이름")
if name != "기계":
given[name] = inputs.get(name, (spec.get("고르기") or [None])[0])
args = "".join(f", {n}='{v}'" for n, v in given.items())
ref = f"로직({logic['키']}, 기계='{code}'{args})"
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", {}, _row(line, ()), None), "계")
return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got
def _codes(row: dict) -> list:
return next(
(i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), []
)
def one_value(item: dict, local: list[dict] | None = None) -> dict:
"""물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`.
기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈.
"""
group, ref = item.get("group"), item.get("ref")
table, field = item.get("table") or {}, item.get("field")
if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref:
ref, _, field = ref.partition(MACHINE_SEP)
try:
if isinstance(ref, str) and _UKEY.match(ref):
got = _user(ref, local or [])
elif group in ("소요량", "계수"):
got = _table(str(table.get("key") or ref or ""), table.get("row"))
elif group == "재료" and _pick(ref) is not None:
got = _material(_pick(ref), str(item.get("unit") or ""))
elif group in ("인력", "재료"):
got = _price(group, str(ref or ""), str(item.get("unit") or ""))
elif group == "기계" and str(ref or "").startswith("로직("):
inputs = item.get("inputs") or {}
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", inputs, _row(line, inputs), None), field)
elif group == "기계" and field == HOURLY:
got = _machine_hourly(str(ref or ""), item.get("inputs") or {})
elif group in ("기계", "유가전력", "환율", "요율"):
got = _plain_value(group, str(ref or ""), field)
else:
got = {"ok": False, "reason": f"모르는 그룹 「{group}」"}
except store.StoreError as e:
got = {"ok": False, "reason": str(e.detail)}
return {"id": item.get("id") or "", **got}
def values(items: list[dict], local: list[dict]) -> dict:
# ponytail: 하나씩 셈(단가 하나 0.3초쯤) — 입력 값이 수십이면 마스터 사본 한 벌로 묶어 셈
return {"values": [one_value(i, local) for i in items]}
# ── 저장 · 계산 ────────────────────────────────────────────────────────
def _col(letters: str) -> int:
n = 0
for ch in letters:
n = n * 26 + ord(ch) - 64
return n
def _letters(n: int) -> str:
out = ""
while n:
n, rest = divmod(n - 1, 26)
out = chr(65 + rest) + out
return out
def _style(cells: dict, cell: str) -> dict:
return {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
def _put(cells: dict, cell: str, value: Any) -> None:
"""받는 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠)."""
keep = _style(cells, cell)
if value is None and not keep:
cells.pop(cell, None)
else:
cells[cell] = {**({"값": value} if value is not None else {}), **keep}
def _place(cells: dict, cell: str, value: Any) -> None:
"""값 하나 · 표 블록(cell 이 왼위) · 수식 `{"식": …}` 을 받는 칸에."""
m = _CELL.match(str(cell or "").upper())
if not m:
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
col, row, at = _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}"
if isinstance(value, dict) and "식" in value:
cells[at] = {**value, **_style(cells, at)}
elif isinstance(value, list):
for dy, line in enumerate(value):
for dx, one in enumerate(line):
_put(cells, f"{_letters(col + dx)}{row + dy}", one)
else:
_put(cells, at, value)
def _plain(v: Any) -> Any:
"""엔진 계산값 한 칸 → 수 · 글 · 참거짓 · 빈 값(오류는 None)."""
if not isinstance(v, dict):
return v
if "수" in v:
return Decimal(str(v["수"]))
if "글" in v:
return v["글"]
if "참" in v:
return v["참"]
return None
def _source(links: list[dict], box: str, port: str) -> dict | None:
return next((ln["from"] for ln in links if ln.get("to") == {"box": box, "name": port}), None)
def _input_ports(box: dict, local: list[dict], errors: list[str]) -> dict[str, Any]:
"""입력 상자 알약 값 — 요소 name · 표 「표」 · 변수 name(예시값)."""
inp, out = box.get("input") or {}, {}
if inp.get("mode") == "group":
group = inp.get("group")
asks = [
(el.get("name"), {"ref": el.get("ref"), "unit": el.get("unit")})
for el in inp.get("elements") or []
]
if inp.get("table"):
asks.append((TABLE_PORT, {"table": inp["table"]}))
for name, ask in asks:
got = one_value({"group": group, **ask}, local)
out[name] = (got.get("rows") or got.get("value")) if got["ok"] else None
if not got["ok"]:
errors.append(f"「{box.get('name')} · {name}」 {got.get('reason')}")
for v in inp.get("variables") or []:
out[v.get("name")] = v.get("example")
return out
def compute(logic: dict, local: list[dict]) -> tuple[dict, dict]:
"""함수 상자 시트를 한 통합문서로 셈 → (값을 되돌려 넣은 다이어그램, 계산 결과)."""
boxes = {b.get("id"): b for b in logic.get("boxes") or []}
links = logic.get("links") or []
errors: list[str] = []
ports = {
i: _input_ports(b, local, errors) for i, b in boxes.items() if b.get("kind") == "input"
}
funcs = [b for b in boxes.values() if b.get("kind") == "function" and b.get("func")]
sheets, fills = [], []
for b in funcs: # 한 통합문서 — 시트 id · 이름 = 상자 id
f = b["func"]
sheet = {**copy.deepcopy(f.get("sheet") or {}), "id": b["id"], "이름": b["id"]}
cells = sheet.setdefault("칸", {})
for v in f.get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], v.get("example"))
for p in f.get("inputs") or []:
src = _source(links, b["id"], p.get("port"))
if src is None or src.get("box") not in boxes:
continue
up = boxes[src["box"]]
if up.get("kind") == "function":
at = ((up.get("func") or {}).get("output") or {}).get("cell") or ""
_place(cells, p["cell"], {"식": f"'{up['id']}'!{at}"})
fills.append((b["id"], p["cell"], ("ref", up["id"], at)))
else:
value = ports.get(up["id"], {}).get(src.get("name"))
_place(cells, p["cell"], value)
fills.append((b["id"], p["cell"], ("value", value, None)))
sheets.append(sheet)
top = [c.get("서식", 0) for s in sheets for c in s["칸"].values() if isinstance(c, dict)]
book = _num(
{"종류": "통합문서", "판": 1, "서식": [{}] * (int(max(top, default=0)) + 1), "시트": sheets}
)
try:
calc = spreadsheet.recalc_workbooks([book])[0] if sheets else {}
except spreadsheet.RecalcError as e:
raise store.StoreError(503, str(e)) from e
def cell_value(box_id: str, cell: str) -> Any:
hit = (calc.get(box_id) or {}).get(cell)
if hit is None:
sheet = next(s for s in book["시트"] if s["id"] == box_id)
return (sheet["칸"].get(cell) or {}).get("값")
if isinstance(hit, dict) and "오류" in hit:
errors.append(f"「{boxes[box_id].get('name')}」 {cell} 칸 계산 오류 {hit.get('오류')}")
return _plain(hit)
for b in funcs:
out = b["func"].get("output") or {}
ports[b["id"]] = {out.get("name"): cell_value(b["id"], out.get("cell") or "")}
outputs, cost = {}, {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}}
for b in boxes.values(): # 출력 상자 · 원가
if b.get("kind") != "output":
continue
src = _source(links, b["id"], OUTPUT_PORT)
value = ports.get(src["box"], {}).get(src.get("name")) if src else None
outputs[b["id"]] = value
kind = (b.get("output") or {}).get("cost")
if (
kind in COSTS
and isinstance(value, (int, float, Decimal))
and not isinstance(value, bool)
):
cost[kind] += Decimal(str(value))
elif kind == "값":
cost["값들"][b.get("name")] = value
cost["계"] = sum(cost[k] for k in COSTS)
done = copy.deepcopy(logic) # 받는 칸에 값만 되돌려 넣음 — 시트 사이 참조는 셈한 값으로
for b in done.get("boxes") or []:
if b.get("kind") != "function" or not b.get("func"):
continue
cells = b["func"].setdefault("sheet", {}).setdefault("칸", {})
for v in b["func"].get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], v.get("example"))
for box_id, cell, (how, a, at) in fills:
if box_id == b["id"]:
_place(cells, cell, cell_value(a, at) if how == "ref" else a)
return done, {"ports": ports, "outputs": outputs, "cost": cost, "errors": errors}
def save(logic: dict) -> dict:
"""저장 — 입력 값 다시 받기 → 함수 상자 시트를 Node 로 셈 → 값만 되돌려 넣어 `_data`."""
if not str(logic.get("name") or "").strip():
raise store.StoreError(400, "이름을 넣어 주세요")
if not isinstance(logic.get("boxes"), list) or not isinstance(logic.get("links"), list):
raise store.StoreError(400, "상자(boxes) · 선(links) 이 없음")
key = logic.get("key") or None
if key and _sample(key) is not None:
key = None # 견본은 그대로 두고 새 번호로
elif key:
_read(key) # 있는 것만 고침 — 없으면 404
done, result = compute(logic, logic.get("userElements") or [])
if not key:
used = [int(m.group(1)) for p in DATA.glob("DF-*.json") if (m := _KEY.match(p.stem))]
key = f"DF-{max(used, default=0) + 1:04d}"
done = {**done, "key": key, "saved": datetime.now().isoformat(timespec="seconds")}
_write(DATA / f"{key}.json", _num(done))
return {"key": key, "logic": done, "values": result}
# ── 길 ────────────────────────────────────────────────────────────────
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/list")
def get_list() -> dict:
return _call(listing)
@router.get("/get")
def get_one(key: str) -> dict:
return _call(get, key)
@router.post("/save")
def post_save(body: LogicBody) -> dict:
return _call(save, body.logic)
@router.post("/delete")
def post_delete(body: KeyBody) -> dict:
return _call(delete, body.key)
@router.post("/values")
def post_values(body: ValuesBody) -> dict:
return _call(values, body.items, body.userElements)
@router.get("/user-elements")
def get_user_elements() -> dict:
return _call(user_elements)
@router.post("/user-elements")
def post_user_elements(body: UserBody) -> dict:
return _call(user_edit, body.op, body.element, body.id)
@@ -0,0 +1,65 @@
/* =============================================================================
* M02_DF_Sample.ts
* 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬)
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비
* ② 공구손료(비율) — 노무비 × 율 → 출력 재료비
* ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
* 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」).
* 모양 = `M02_DF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port ·
* output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { CfLogic } from "./M02_DF_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 @@
.m02-dfsum__big {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
.m02-dfsum__big strong {
color: var(--color-text-body);
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.m02-dfsum__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);
}
.m02-dfsum__costs dt {
display: inline;
color: var(--color-text-secondary);
}
.m02-dfsum__costs dd {
display: inline;
margin: 0 0 0 var(--spacing-4);
color: var(--color-text-body);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m02-dfsum__block {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m02-dfsum__scroll {
max-width: 100%;
overflow-x: auto;
}
.m02-dfsum__table {
width: 100%;
min-width: 360px;
margin-top: var(--spacing-4);
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m02-dfsum__table th,
.m02-dfsum__table td {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.m02-dfsum__table th {
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: 600;
white-space: nowrap;
}
.m02-dfsum__table td.m02-dfsum__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m02-dfsum__strip {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m02-dfsum__fields {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-16);
margin: var(--spacing-8) 0;
}
.m02-dfsum__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-dfsum__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);
}
.m02-dfsum__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m02-dfsum__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-dfsum__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);
}
.m02-dfsum__problems ul {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
}
@@ -0,0 +1,279 @@
/* =============================================================================
* M02_DF_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, type CfValues } from "./M02_DF_Eval";
import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model";
import { showValue } from "./M02_DF_OutputPanel";
import "./M02_DF_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: "m02-dfsum__input",
attrs: {
type: "text",
inputmode: v.type === "number" ? "decimal" : "text",
"aria-label": v.name,
},
});
if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) });
box.value = v.example === null ? "" : String(v.example);
box.addEventListener("change", () => {
const raw = box.value.trim();
if (raw === "") return;
if (v.type !== "number") return put(raw);
const n = Number(raw);
if (Number.isNaN(n)) msg.textContent = ts("Bad");
else put(n);
});
control = box;
}
return el("label", { className: "m02-dfsum__field", children: [label, control] });
}
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
export function renderSummary(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on?: { change(lg: CfLogic): void },
opts?: { values?: CfValues },
): void {
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
const perText = !perUnit
? ts("Total")
: ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
const { cost } = result;
const big = el("p", {
className: "m02-dfsum__big",
children: [
el("span", { text: money ? perText : ts("Total") }),
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
],
});
const costs = el("dl", {
className: "m02-dfsum__costs",
children: COSTS.flatMap((c) => [
el("dt", { text: ts(COST_KEY[c]) }),
el("dd", { text: formatMoney(cost[c]) }),
]),
});
/* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */
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: "m02-dfsum__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: "m02-dfsum__block",
children: [
el("strong", { text: title }),
el("div", {
className: "m02-dfsum__scroll",
children: [
el("table", {
className: "m02-dfsum__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, opts?.values ?? {});
} catch {
checked = [];
}
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
/* 예시값 — 변수 상자의 변수마다 */
const msg = el("p", { className: "m02-dfsum__msg" });
const fields = lg.boxes
.filter((b) => b.kind === "input" && b.input?.mode === "variable")
.flatMap((b) =>
(b.input?.variables ?? []).map((v, i) =>
sampleField(
v,
(value) => {
msg.textContent = "";
if (!on) return;
try {
on.change({
...lg,
boxes: lg.boxes.map((x) =>
x.id === b.id && x.input
? {
...x,
input: {
...x.input,
variables: x.input.variables.map((y, j) =>
j === i ? { ...y, example: value } : y,
),
},
}
: x,
),
});
} catch {
msg.textContent = ts("Cant");
}
},
msg,
),
),
);
const 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: "m02-dfsum__block",
children: [
el("strong", { text: ts("Plain") }),
...plainValues.map(([k, v]) =>
el("p", { className: "m02-dfsum__muted", text: `${k} · ${showValue(v)}` }),
),
],
}),
]
: []),
...(fields.length
? [
el("div", {
className: "m02-dfsum__strip",
children: [
el("strong", { text: ts("Sample") }),
el("div", { className: "m02-dfsum__fields", children: fields }),
el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }),
msg,
],
}),
]
: []),
...(problems.length
? [
el("div", {
className: "m02-dfsum__problems",
children: [
el("strong", { text: ts("Problems") }),
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
],
}),
]
: []),
);
}
@@ -0,0 +1,785 @@
{
"key": "SAMPLE-masonry",
"name": "견본 · 돌쌓기 메쌓기(인력)",
"resultUnit": "원/㎡",
"boxes": [
{
"id": "in-var",
"kind": "input",
"name": "쌓기 조건",
"color": "#2f80ed",
"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": "#2f80ed",
"unit": "-",
"x": 40,
"y": 300,
"input": {
"mode": "group",
"group": "소요량",
"elements": [],
"table": {
"key": "QF000421"
},
"variables": []
}
},
{
"id": "in-rate",
"kind": "input",
"name": "높이 증가율표",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 460,
"input": {
"mode": "group",
"group": "소요량",
"elements": [],
"table": {
"key": "QF000422"
},
"variables": []
}
},
{
"id": "in-labor",
"kind": "input",
"name": "인력",
"color": "#2f80ed",
"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": "#7b4fd6",
"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": "#7b4fd6",
"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": "#7b4fd6",
"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": "#7b4fd6",
"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": "#7b4fd6",
"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": "#7b4fd6",
"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": "#27ae60",
"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": "#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": []
}
@@ -0,0 +1,200 @@
{
"key": "SAMPLE-value",
"name": "견본 · 덤프트럭 시간당 작업량(값)",
"resultUnit": "㎥/시간",
"boxes": [
{
"id": "in-var",
"kind": "input",
"name": "덤프트럭 조건",
"color": "#2f80ed",
"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": "#7b4fd6",
"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": "#27ae60",
"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,42 @@
{
"이름": "금액의 단위표준",
"출처": "산림품셈 1-2-2 2",
"원문": "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md",
"줄": [
{
"종목": "설계서의 총액",
"단위": "원",
"지위": 1000,
"비고": "미만버림",
"원문 줄": 145
},
{
"종목": "설계서의 소계",
"단위": "원",
"지위": 1,
"비고": "미만버림",
"원문 줄": 146
},
{
"종목": "설계서의 금액란",
"단위": "원",
"지위": 1,
"비고": "미만버림",
"원문 줄": 147
},
{
"종목": "일위대가표의 계금",
"단위": "원",
"지위": 1,
"비고": "미만버림",
"원문 줄": 148
},
{
"종목": "일위대가표의 금액란",
"단위": "원",
"지위": 0.1,
"비고": "미만버림",
"원문 줄": 149
}
]
}
@@ -0,0 +1,680 @@
{
"이름": "설계서의 단위 및 소수의 표준",
"출처": "산림품셈 1-2-2 1",
"원문": "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md",
"바꿈": "소수자리 「-」 = 0 · 단위 「-」 = 「없음」 · 빈 칸 = null · 병합 칸은 위 종목을 폄(「병합」: true)",
"줄": [
{
"종목": "공사연장",
"규격 단위": "m",
"규격 소수자리": 2,
"단위수량 단위": "m",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 60
},
{
"종목": "공사폭원",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 61
},
{
"종목": "직공인부",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "인",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 62
},
{
"종목": "공사 및 사업면적",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 63
},
{
"종목": "용지면적",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 64
},
{
"종목": "토적(높이, 너비)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 65
},
{
"종목": "토적(단면적)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 66
},
{
"종목": "토적(체적)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 67
},
{
"종목": "토적(체적합계)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 68
},
{
"종목": "떼",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 69
},
{
"종목": "모래, 자갈",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 70
},
{
"종목": "조약돌",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 71
},
{
"종목": "견치돌, 깬돌",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 72
},
{
"종목": "견치돌, 깬돌",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": null,
"비고": "",
"원문 줄": 73,
"병합": true
},
{
"종목": "야면석(野面石)",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 74
},
{
"종목": "돌쌓기 및 돌붙임",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 75
},
{
"종목": "돌쌓기 및 돌붙임",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 76,
"병합": true
},
{
"종목": "돌쌓기 및 돌붙임",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 77,
"병합": true
},
{
"종목": "돌쌓기 및 돌붙임",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 78,
"병합": true
},
{
"종목": "사석(捨石)",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 79
},
{
"종목": "다듬돌(切石, 板石)",
"규격 단위": "cm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 80
},
{
"종목": "벽돌",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 81
},
{
"종목": "블록",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 82
},
{
"종목": "시멘트",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 83
},
{
"종목": "모르타르",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 84
},
{
"종목": "콘크리트",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎥",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 85
},
{
"종목": "석분",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 86
},
{
"종목": "석회",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 87
},
{
"종목": "화산회",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 88
},
{
"종목": "아스팔트",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 89
},
{
"종목": "목재(판재)",
"규격 단위": "길이m",
"규격 소수자리": 1,
"단위수량 단위": "㎡",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 90
},
{
"종목": "목재(판재)",
"규격 단위": "폭,두께cm",
"규격 소수자리": 1,
"단위수량 단위": "㎥",
"단위수량 소수자리": 3,
"비고": "",
"원문 줄": 91,
"병합": true
},
{
"종목": "합판",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "장",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 92
},
{
"종목": "말뚝",
"규격 단위": "길이m",
"규격 소수자리": 1,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 93
},
{
"종목": "말뚝",
"규격 단위": "지름mm",
"규격 소수자리": 0,
"단위수량 단위": null,
"단위수량 소수자리": null,
"비고": "",
"원문 줄": 94,
"병합": true
},
{
"종목": "철강재",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 3,
"비고": "",
"원문 줄": 95
},
{
"종목": "용접봉",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 96
},
{
"종목": "구리판, 함석류",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 97
},
{
"종목": "철근",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 98
},
{
"종목": "볼트・너트",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 99
},
{
"종목": "꺽쇠",
"규격 단위": "mm",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 100
},
{
"종목": "철선류",
"규격 단위": "mm",
"규격 소수자리": 1,
"단위수량 단위": "kg",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 101
},
{
"종목": "PC강선",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 102
},
{
"종목": "돌망태",
"규격 단위": "길이m",
"규격 소수자리": 1,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 103
},
{
"종목": "돌망태",
"규격 단위": "지름m",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 104,
"병합": true
},
{
"종목": "돌망태",
"규격 단위": "높이m",
"규격 소수자리": 0,
"단위수량 단위": null,
"단위수량 소수자리": null,
"비고": "",
"원문 줄": 105,
"병합": true
},
{
"종목": "로프류",
"규격 단위": "mm",
"규격 소수자리": 1,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 106
},
{
"종목": "못",
"규격 단위": "길이cm",
"규격 소수자리": 1,
"단위수량 단위": "kg",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 107
},
{
"종목": "석유, 휘발유, 모빌유",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "ℓ",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 108
},
{
"종목": "구리스",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 109
},
{
"종목": "넝마",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 110
},
{
"종목": "화약류",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 3,
"비고": "",
"원문 줄": 111
},
{
"종목": "뇌관",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 112
},
{
"종목": "도화선",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 113
},
{
"종목": "석탄, 목탄, 코크스",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 114
},
{
"종목": "산소",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "ℓ",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 115
},
{
"종목": "카바이트",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "kg",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 116
},
{
"종목": "도료(塗料)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "ℓ또는kg",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 117
},
{
"종목": "도장(塗裝)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 118
},
{
"종목": "관류(管類)",
"규격 단위": "길이m",
"규격 소수자리": 2,
"단위수량 단위": "개",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 119
},
{
"종목": "관류(管類)",
"규격 단위": "지름mm",
"규격 소수자리": 0,
"단위수량 단위": null,
"단위수량 소수자리": null,
"비고": "",
"원문 줄": 120,
"병합": true
},
{
"종목": "관류(管類)",
"규격 단위": "두께mm",
"규격 소수자리": 0,
"단위수량 단위": null,
"단위수량 소수자리": null,
"비고": "",
"원문 줄": 121,
"병합": true
},
{
"종목": "수로연장",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 122
},
{
"종목": "옹벽",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 123
},
{
"종목": "승강장옹벽 및 울타리",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 124
},
{
"종목": "궤도부설",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "km",
"단위수량 소수자리": 3,
"비고": "",
"원문 줄": 125
},
{
"종목": "시험하중",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "ton",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 126
},
{
"종목": "보오링",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 127
},
{
"종목": "방수면적",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 128
},
{
"종목": "건물(면적)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 2,
"비고": "",
"원문 줄": 129
},
{
"종목": "건물(지붕, 벽붙이기)",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "㎡",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 130
},
{
"종목": "우물",
"규격 단위": "깊이",
"규격 소수자리": 0,
"단위수량 단위": "m",
"단위수량 소수자리": 1,
"비고": "",
"원문 줄": 131
},
{
"종목": "마대",
"규격 단위": "없음",
"규격 소수자리": 0,
"단위수량 단위": "매",
"단위수량 소수자리": 0,
"비고": "",
"원문 줄": 132
}
],
"주": [
"① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용한다.",
"② 품셈 각 항목에서 제시한 소숫자리가 본 표의 내용과 상이할 경우 항목에서 제시하는 소숫자리를 우선하여 적용한다.",
"③ 본 표에 제시하지 않은 품의 경우 유사품의 규격과 단위수량을 참고하여 적용하며, C.G.S 단위로 하는 것을 원칙으로 한다."
]
}
@@ -0,0 +1,254 @@
"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`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"));
const out = jobs.map((j) => {
if (j.op === "fn") return m[j.name](...j.args);
let lg = j.lg;
for (const [fn, ...args] of j.steps || []) lg = m[fn](lg, ...args);
return lg;
});
process.stdout.write(JSON.stringify(out));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def logic() -> dict:
plain = {"color": "#fff", "unit": "-", "x": 0, "y": 0}
return {
"key": None,
"name": "견본",
"resultUnit": "원/㎡",
"userElements": [],
"boxes": [
{
"id": "b1",
"kind": "input",
"name": "입력",
**plain,
"input": {"mode": "variable", "elements": [], "variables": []},
},
{"id": "b2", "kind": "function", "name": "함수", **plain},
],
"links": [],
}
def call(name: str, *args):
return run([{"op": "fn", "name": name, "args": list(args)}])[0]
def edit(steps: list, lg: dict | None = None) -> dict:
return run([{"op": "lg", "lg": lg or logic(), "steps": steps}])[0]
LABOR = {"키": "LB000033", "이름": "석공", "규격": "", "단위": "원/일", "값": 268908}
LABOR2 = {"키": "LB000002", "이름": "보통인부", "규격": "", "단위": "원/일", "값": 172068}
MAT = {
"키": "MT1",
"이름": "각형흄관",
"규격": "D=300mm",
"단위": "M",
"물가자료": 30000,
"물가정보": 29600,
}
MACHINE = {
"키": "EQ000001",
"이름": "불도저",
"규격": "7ton",
"단위": {"가격": "천원", "손료계수": "10^-7/시간"},
"값": {"가격": 76547, "손료계수": 1811},
}
RATE_ONE = {"키": "RT1", "이름": "산재보험료", "값": {"rate_percent": 3.56}}
RATE_BRACKET = {"키": "RT2", "이름": "고용보험료", "값": {"brackets": []}}
def test_요소는_이름과_규격으로_알약_이름이_됨():
labor = call("elementOf", "인력", LABOR)
assert labor == {"ref": "LB000033", "name": "석공", "spec": "", "unit": "원/일"}
got = call("elementOf", "재료", MAT)
assert got["name"] == "각형흄관 D=300mm" and got["unit"] == "M"
def test_기계는_값_칸을_골라_요소가_됨():
assert call("machineFields", MACHINE) == ["가격", "손료계수"]
got = call("elementOf", "기계", MACHINE, "손료계수")
assert got["ref"] == "EQ000001#손료계수" and got["unit"] == "10^-7/시간"
assert got["name"].endswith("· 손료계수")
assert call("queryOf", "기계", "EQ000001#손료계수") == {
"group": "기계",
"ref": "EQ000001",
"field": "손료계수",
}
def test_값_읽기_재료는_낮은_값_요율은_비율():
assert call("valueOf", "재료", MAT) == 29600
assert call("valueOf", "요율", RATE_ONE) == 3.56
assert call("valueOf", "요율", RATE_BRACKET) is None
assert call("elementOf", "요율", RATE_ONE)["unit"] == "%"
assert call("elementOf", "요율", RATE_BRACKET)["unit"] == "-"
assert call("valueOf", "기계", MACHINE, "가격") == 76547
def test_사용자_요소는_그_그룹의_ref_로_물음():
assert call("queryOf", "인력", "UE-0001") == {"group": "인력", "ref": "UE-0001"}
assert call("queryOf", "재료", "MT1") == {"group": "재료", "ref": "MT1"}
row = {"키": "UE-0001", "이름": "특수공", "규격": "", "단위": "원/일", "값": 300000}
assert call("elementOf", "인력", row)["unit"] == "원/일"
assert call("elementOf", "요율", {**row, "단위": "%"})["unit"] == "%"
def test_상자_단위는_같으면_그_단위_여럿이면_하이픈():
one = edit([["setKind", "b1", "인력"], ["addElement", "b1", call("elementOf", "인력", LABOR)]])
assert one["boxes"][0]["unit"] == "원/일"
two = edit([["addElement", "b1", call("elementOf", "인력", LABOR2)]], one)
assert two["boxes"][0]["unit"] == "원/일" and len(two["boxes"][0]["input"]["elements"]) == 2
oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"}
assert edit([["addElement", "b1", oil]], two)["boxes"][0]["unit"] == "-"
def test_같은_요소_이름은_한_번만():
el = call("elementOf", "인력", LABOR)
lg = edit(
[
["setKind", "b1", "인력"],
["addElement", "b1", el],
["addElement", "b1", el],
["addElement", "b1", {**el, "ref": "다른"}],
]
)
assert len(lg["boxes"][0]["input"]["elements"]) == 1
def test_요소를_빼면_그_알약에서_나간_선도_지움():
el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2)
base = edit([["setKind", "b1", "인력"], ["addElement", "b1", el1], ["addElement", "b1", el2]])
base["links"] = [
{"id": "l1", "from": {"box": "b1", "name": "석공"}, "to": {"box": "b2", "name": "a"}},
{"id": "l2", "from": {"box": "b1", "name": "보통인부"}, "to": {"box": "b2", "name": "b"}},
]
out = edit([["removeElement", "b1", "LB000033"]], base)
assert [x["id"] for x in out["links"]] == ["l2"]
assert [e["name"] for e in out["boxes"][0]["input"]["elements"]] == ["보통인부"]
def test_갈래를_바꾸면_고른_것을_비움_표는_요소와_함께_못_둠():
el = call("elementOf", "인력", LABOR)
lg = edit([["setKind", "b1", "인력"], ["addElement", "b1", el], ["setKind", "b1", "재료"]])
assert lg["boxes"][0]["input"]["group"] == "재료"
assert lg["boxes"][0]["input"]["elements"] == []
t = edit([["setKind", "b1", "소요량"], ["setTable", "b1", {"key": "QF000421"}, "메쌓기(인력)"]])
assert t["boxes"][0]["input"]["table"] == {"key": "QF000421"}
assert t["boxes"][0]["name"] == "메쌓기(인력)" and t["boxes"][0]["unit"] == "-"
row = edit([["setTable", "b1", {"key": "QF000421", "row": 3}]], t)
assert row["boxes"][0]["input"]["table"] == {"key": "QF000421", "row": 3}
assert call("pillNames", row["boxes"][0]) == ["표"]
def test_변수_넣기_고치기_삭제_알약_이름():
v = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]}
lg = edit([["setKind", "b1", "변수"], ["putVariable", "b1", v]])
box = lg["boxes"][0]
assert box["input"]["mode"] == "variable" and box["unit"] == "㎡"
assert call("pillNames", box) == ["면적"]
renamed = edit([["putVariable", "b1", {**v, "name": "연장", "unit": "m"}, "면적"]], lg)
assert call("pillNames", renamed["boxes"][0]) == ["연장"] and renamed["boxes"][0]["unit"] == "m"
gone = edit([["removeVariable", "b1", "연장"]], renamed)
assert gone["boxes"][0]["input"]["variables"] == [] and gone["boxes"][0]["unit"] == "-"
def test_변수_검사는_쉬운_말():
ok = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]}
assert call("checkVariable", ok, []) is None
assert call("checkVariable", {**ok, "name": " "}, []) == "이름을 적어 주세요"
assert call("checkVariable", ok, [ok]) == "같은 이름의 변수가 이미 있음"
assert call("checkVariable", {**ok, "range": [9, 1]}, []) == "범위는 아래가 위보다 작아야 함"
assert call("checkVariable", {**ok, "example": 5000}, []) == "예시값이 범위 밖"
many = {**ok, "type": "choice", "choices": ["가"]}
assert call("checkVariable", many, []) == "선택 목록은 둘 이상"
bad = {**ok, "type": "choice", "choices": ["가", "나"], "example": "다"}
assert call("checkVariable", bad, []) == "예시값이 선택 목록에 없음"
def test_환율_유가전력_알약_이름에_날짜가_안_들어감():
fx = {"키": "FX1", "이름": "USD", "규격": "2026-08-14", "단위": "원/USD", "값": 1414.9}
assert call("elementOf", "환율", fx)["name"] == "USD"
oil = {
"키": "MO1",
"이름": "휘발유",
"규격": "2026-08-14",
"상세구분": "서울",
"단위": "원/L",
"값": 1900,
}
assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울"
def test_단위가_안_맞게_저장된_상자는_열_때_맞춤():
el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2)
lg = logic()
lg["boxes"][0]["input"] = {
"mode": "group",
"group": "인력",
"elements": [el1, el2],
"variables": [],
}
assert lg["boxes"][0]["unit"] == "-"
fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]
assert fixed["boxes"][0]["unit"] == "원/일"
again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0]
assert again == fixed
oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"}
lg["boxes"][0]["input"]["elements"].append(oil)
lg["boxes"][0]["unit"] = "원/일"
assert (
run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"]
== "-"
)
one = {
"mode": "variable",
"elements": [],
"variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}],
}
lg["boxes"][0]["input"] = one
assert (
run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"]
== "㎡"
)
+348
View File
@@ -0,0 +1,348 @@
"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_DF_Model.ts` · `M02_DF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림.
· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함
· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사
· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길)
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
CF = ROOT / "M02_MasterTemplete" / "DataFlow"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
LOADER = r"""
const fs = require("fs");
const path = require("path");
const ROOT = process.argv[1];
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full).exports;
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const mod = { exports: {} };
cache.set(full, mod);
const req = (r) => {
if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 };
if (r.endsWith(".css")) return {};
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`));
return require(r);
};
new Function("exports", "module", "require", js)(mod.exports, mod, req);
return mod.exports;
}
const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Model.ts"));
const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Eval.ts"));
const out = (x) => console.log(JSON.stringify(x));
const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y);
const join = (lg, a, ap, b, bp) => {
const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp });
if (r.why) throw new Error(r.why);
return r.lg;
};
/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */
const func = (lg, box, cells, inputs, output, variables = []) =>
M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables,
output: { ...box.func.output, ...output } } });
"""
def _run(body: str) -> object:
result = subprocess.run(
["node", "-e", LOADER + body, str(ROOT)],
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.strip().splitlines()[-1])
# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비
ONE = r"""
let lg = M.newLogic("견본 하나", "원/㎡");
let r = add(lg, "input"); lg = r.lg; let man = r.box;
lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력",
elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } });
r = add(lg, "input"); lg = r.lg; let area = r.box;
lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [],
variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } });
r = add(lg, "function"); lg = r.lg; let f = r.box;
lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } },
[{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" },
[{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]);
r = add(lg, "output"); lg = r.lg; let o = r.box;
lg = join(lg, man, "석공", f, "노임");
lg = join(lg, area, "면적", f, "면적");
lg = join(lg, f, "노무비", o, "값");
const V = { [man.id]: { 석공: 268908 } };
"""
# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬)
TWO = r"""
let lg = M.newLogic("견본 둘");
let r = add(lg, "input"); lg = r.lg; const inp = r.box;
lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력",
elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }],
variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } });
const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box;
lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; };
const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]);
const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]);
const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]);
const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]);
lg = join(lg, inp, "면적", f1, "가");
lg = join(lg, inp, "보통인부", f2, "가");
lg = join(lg, f1, "결과", f3, "가");
lg = join(lg, f2, "결과", f3, "나");
lg = join(lg, f3, "결과", f4, "가");
r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } });
r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } });
r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } });
lg = join(lg, f4, "결과", oExp, "값");
lg = join(lg, f1, "결과", oVal, "값");
lg = join(lg, inp, "면적", oMat, "값");
const V = { [inp.id]: { 보통인부: 172068 } };
"""
def test_견본하나_입력_변수_함수_출력_노무비() -> None:
got = _run(
ONE
+ "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);"
)
ev, check, boxes = got
assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908
assert ev["outputs"] == {"b4": 537816}
assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816}
assert ev["errors"] == [] and check == []
assert boxes == [
["b1", "input", "입력", "#2f80ed", "-"],
["b2", "input", "입력 2", "#2f80ed", "-"],
["b3", "function", "함수", "#7b4fd6", "-"],
["b4", "output", "출력", "#27ae60", "-"],
]
def test_변수값_바꾸면_값이_바뀜() -> None:
got = _run(
ONE
+ "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);"
)
assert got == 0.1 * 20 * 268908
def test_견본둘_직렬_병렬_원가_합() -> None:
got = _run(
TWO
+ "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);"
)
ports, cost, errors, check = got
f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5"))
assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938)
assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943}
assert errors == [] and check == []
def test_선_규칙() -> None:
got = _run(
TWO
+ "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n"
"out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적'),"
" t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값'),"
" M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);"
)
assert got == [
"이으면 돌고 도는 선이 됨",
"이으면 돌고 도는 선이 됨",
"출력 상자에서는 선이 나가지 않음",
"입력 상자로는 선이 들어가지 않음",
"같은 상자끼리는 못 이음",
"이미 선이 들어온 자리",
"알약이 없음",
"이미 선이 들어온 자리",
True,
]
def test_알약_출력은_하나() -> None:
got = _run(
TWO
+ "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n"
"out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3]),"
" M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);"
)
in_out, in_in, f_out, f_in, o_out, o_in = got
assert in_out == [
{"name": "보통인부", "unit": "인"},
{"name": "표", "unit": "-"},
{"name": "면적", "unit": "㎡"},
]
assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}]
assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}]
assert o_out == [] and o_in == [{"name": "값", "unit": "원"}]
def test_선_끊기_상자_지우기_검사() -> None:
got = _run(
TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n"
"const a = M.unlink(lg, cut);\n"
"const b = M.removeBox(lg, f3.id);\n"
"const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n"
"out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length,"
" E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);"
)
f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got
assert f3_after == {"결과": 10}
assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"]
assert (n_b, n_all) == (5, 8)
assert check_b == [
"「함수 4」 의 받는 알약 「가」 에 선이 없음",
]
assert check_c == ["출력 상자가 없음"]
assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"]
def test_오류칸_순환칸_숫자아닌_출력() -> None:
got = _run(
ONE
+ "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet,"
" 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n"
"const a = E.evalLogic(lg, V);\n"
"lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet,"
" 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n"
"const b = E.evalLogic(lg, V);\n"
"out([a.errors, a.cost.계, b.errors]);"
)
a_err, total, b_err = got
assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"]
assert total == 0
assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err
def test_자리_색_단위() -> None:
got = _run(
ONE
+ "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n"
"g = M.setUnit(g, o.id, ' ');\n"
"const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n"
"out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);"
)
assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0]
def test_사용자_요소_값() -> None:
got = _run(
ONE
+ "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n"
"lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n"
"out(E.evalLogic(lg, {}).cost.노무비);"
)
assert got == 0.2 * 10 * 300000
def test_화면_글에_금지_낱말_없음() -> None:
for name in ("M02_DF_Model.ts", "M02_DF_Eval.ts"):
src = (CF / name).read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert korean
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in text, (name, bad, text)
def test_알약_이름_규칙() -> None:
got = _run(
"let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n"
"lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료',"
" elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 },"
" variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n"
"out(M.portsOut(lg.boxes[0]).map(p => p.name));"
)
assert got == ["시멘트", "표", "길이"]
def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None:
got = _run(
ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n"
"let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n"
"let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n"
"let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n"
"let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n"
"const gb = (x) => x.boxes.find(y => y.id === g.id);\n"
"const inputs = gb(d.lg).func.inputs;\n"
"const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n"
"const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n"
"const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n"
"const removed = M.removeBox(d.lg, man.id);\n"
"out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to),"
" check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port),"
" M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);"
)
a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got
assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None)
assert inputs == [
{"port": "석공", "cell": ""},
{"port": "면적", "cell": ""},
{"port": "노무비", "cell": ""},
]
assert ports == ["석공", "면적", "노무비"]
assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}]
assert check == [
"「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음",
"「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음",
"「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음",
]
assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음
assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움
assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음
def test_같은_이름_받는_알약은_번호() -> None:
got = _run(
ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n"
"let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n"
"x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n"
"r = add(x, 'input'); x = r.lg; const m2 = r.box;\n"
"x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n"
"x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n"
"x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n"
"out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));"
)
assert got == ["석공", "노무비", "석공 (2)", "할증"]
def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None:
got = _run(
"let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n"
"r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n"
"r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n"
"lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n"
"lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n"
"lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n"
"r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n"
"lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n"
"lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n"
"out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값]),"
" lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);"
)
names, ports = got
assert names == [
["결과 1", "결과 1"],
["결과 3", "결과 3"],
["결과 2", "결과 2"],
["결과 4", "결과 4"],
]
assert ports == ["결과 1", "결과 2", "결과 1 (2)"]
+353
View File
@@ -0,0 +1,353 @@
"""단가산출 다이어그램 시험 길(`M02_MasterTemplete/DataFlow/M02_DF_Router.py`) 시험 — PLAN 35-7.
· Model CfLogic(boxes · links · userElements) 저장 → 열기 → 목록 → 지우기 · 견본(`_sample`)은 읽기만
· 저장 때 입력 값을 마스터에서 다시 받고 함수 상자 시트를 한 통합문서로 Node 셈 →
받는 칸에 값만 되돌림
(입력 → 함수 · 함수 → 함수 직렬 · 표 블록 · 변수 · 출력 상자 비용 종류 · 원가)
· 입력 값 — 인력 · 재료(키 · 검색어 묶음) · 유가전력 · 환율 · 요율 · 기계 값 칸 · 표 블록 ·
한 줄 · 사용자 요소
· 사용자 요소 만들기 · 지우기 · 권한 없음 403 · 마스터 사본 파일은 안 바뀜
견본 표 · 다이어그램은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 값에 안 기댐).
"""
from __future__ import annotations
import json
import shutil
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete.DataFlow import M02_DF_Router as cf
REAL = store.FOLDER
OWN = "로직_자체.json"
SAMPLE = "QF999995"
TABLE = {
"키": SAMPLE,
"원문번호": "",
"구분": "",
"상세구분": "",
"이름": "견본 높이 표",
"기준": "1㎡",
"출처": "자체",
"조건": {"돌": "고르기", "높이": "범위"},
"범위규칙": "초과·이하",
"값칸": {"석공": "인", "증가율": "%"},
"줄": [
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]},
{"돌": "견치돌", "높이": 4, "석공": 0.7},
],
"주": [],
}
HEAD = ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"]
NO_NODE = shutil.which("node") is None
def _first(name: str, pred) -> dict:
rows = json.loads((store.FOLDER / name).read_text(encoding="utf-8"))["줄"]
return next(r for r in rows if pred(r))
def _labor() -> tuple[str, Decimal]:
row = _first(
"인력.json",
lambda r: (
r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float))
),
)
return row["키"], Decimal(str(row["값"]))
def _box(id_: str, kind: str, name: str, **part) -> dict:
return {"id": id_, "kind": kind, "name": name, "color": "", "unit": "-", "x": 0, "y": 0, **part}
def _func(sheet: dict, inputs: list, output: dict, variables: list | None = None) -> dict:
return {"sheet": sheet, "inputs": inputs, "variables": variables or [], "output": output}
def _link(i: str, a: str, pa: str, b: str, pb: str) -> dict:
return {"id": i, "from": {"box": a, "name": pa}, "to": {"box": b, "name": pb}}
def _logic(labor: str, key: str | None = None) -> dict:
"""노임 × 2 × 면적 × (1 + 할증%) = 노무비 → +1 = 경비(함수 → 함수) · 표 블록 E1 → H3 = 품(값)
· 브라우저가 보낸 받는 칸 값(999)은 엉터리."""
s1 = {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 999}, "B1": {"식": "A1*2*A2*(1+A3/100)"}}}
group = {"mode": "group", "elements": [], "variables": []}
return {
"key": key,
"name": "견본 다이어그램",
"resultUnit": "원/㎡",
"boxes": [
_box("in1", "input", "인력", input={**group, "group": "인력",
"elements": [{"ref": labor, "name": "석공", "spec": "", "unit": "인"}]}),
_box("in2", "input", "표",
input={**group, "group": "소요량", "table": {"key": SAMPLE}}),
_box("in3", "input", "면적", input={"mode": "variable", "elements": [], "variables": [
{"name": "면적", "type": "number", "unit": "㎡", "example": 3}]}),
_box("f1", "function", "노무 셈", func=_func(
s1, [{"port": "노임", "cell": "A1"}, {"port": "면적", "cell": "A2"}],
{"cell": "B1", "name": "노무비", "unit": "원"},
[{"name": "할증", "type": "number", "unit": "%", "example": 10, "cell": "A3"}])),
_box("f2", "function", "더하기", func=_func(
{"id": "s2", "이름": "더하기", "칸": {"B1": {"식": "A1+1"}}},
[{"port": "앞", "cell": "A1"}], {"cell": "B1", "name": "합", "unit": "원"})),
_box("f3", "function", "표 찾기", func=_func(
{"id": "s3", "이름": "표 찾기", "칸": {"A1": {"식": "H3"}}},
[{"port": "표", "cell": "E1"}], {"cell": "A1", "name": "품", "unit": "인"})),
_box("o1", "output", "노무비", output={"cost": "노무비", "unit": "원"}),
_box("o2", "output", "경비", output={"cost": "경비", "unit": "원"}),
_box("o3", "output", "품", output={"cost": "값", "unit": "인"}),
],
"links": [
_link("l1", "in1", "석공", "f1", "노임"),
_link("l2", "in3", "면적", "f1", "면적"),
_link("l3", "f1", "노무비", "f2", "앞"),
_link("l4", "in2", "표", "f3", "표"),
_link("l5", "f1", "노무비", "o1", "값"),
_link("l6", "f2", "합", "o2", "값"),
_link("l7", "f3", "품", "o3", "값"),
],
"userElements": [],
} # fmt: skip
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
master = tmp_path / "master"
master.mkdir()
for path in REAL.glob("*.json"):
if path.name == OWN:
continue
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, master / path.name)
host = sorted(master.glob("소요량_*.json"))[0]
data = json.loads(host.read_text(encoding="utf-8"))
data["표"].append(TABLE)
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
sample = tmp_path / "_sample"
sample.mkdir()
one = {"key": None, "name": "견본 돌쌓기", "resultUnit": "원/㎡", "boxes": [], "links": [],
"userElements": []} # fmt: skip
(sample / "견본 돌쌓기.json").write_text(json.dumps(one, ensure_ascii=False), encoding="utf-8")
monkeypatch.setattr(store, "FOLDER", master)
monkeypatch.setattr(cf, "DATA", tmp_path / "_data")
monkeypatch.setattr(cf, "SAMPLE", sample)
return master
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(m01_router.router)
app.include_router(cf.router)
return TestClient(app)
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def _d(v) -> Decimal:
return Decimal(str(v))
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_저장_서버가_다시_셈_값만_되돌림(client: TestClient, folder: Path) -> None:
labor, price = _labor()
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
saved = _post(client, "/api/m02/df/save", {"logic": _logic(labor)})
assert saved["key"] == "DF-0001"
v = saved["values"]
labor_cost = price * 2 * 3 * Decimal("1.1")
assert _d(v["ports"]["f1"]["노무비"]) == labor_cost
assert _d(v["ports"]["f2"]["합"]) == labor_cost + 1 # 함수 → 함수(직렬)
assert _d(v["ports"]["f3"]["품"]) == Decimal("0.7") # 표 블록 E1 부터 → H3
assert _d(v["cost"]["노무비"]) == labor_cost and _d(v["cost"]["경비"]) == labor_cost + 1
assert _d(v["cost"]["계"]) == 2 * labor_cost + 1 and _d(v["cost"]["값들"]["품"]) == Decimal(
"0.7"
)
assert v["errors"] == []
boxes = {b["id"]: b for b in saved["logic"]["boxes"]}
c1 = boxes["f1"]["func"]["sheet"]["칸"]
assert c1["A1"]["값"] == price and c1["A2"]["값"] == 3 and c1["A3"]["값"] == 10 # 999 는 버림
assert c1["B1"] == {"식": "A1*2*A2*(1+A3/100)"} and boxes["f1"]["func"]["sheet"]["id"] == "s1"
assert _d(boxes["f2"]["func"]["sheet"]["칸"]["A1"]["값"]) == labor_cost # 참조 아닌 값으로
c3 = boxes["f3"]["func"]["sheet"]["칸"]
assert [c3[f"{c}1"]["값"] for c in "EFGHIJ"] == HEAD and c3["F3"]["값"] == c3["G3"]["값"] == 4
assert saved["logic"]["links"] == _logic(labor)["links"]
# 열기 · 목록 · 견본 · 고치기 · 지우기
listed = client.get("/api/m02/df/list").json()["logics"]
assert [(x["key"], x["sample"]) for x in listed] == [("DF-0001", False), ("견본 돌쌓기", True)]
sample = client.get("/api/m02/df/get", params={"key": "견본 돌쌓기"}).json()
assert sample["sample"] is True
assert _post(client, "/api/m02/df/save", {"logic": sample["logic"]})["key"] == "DF-0002"
_post(client, "/api/m02/df/delete", {"key": "견본 돌쌓기"}, want=403)
renamed = {**saved["logic"], "name": "고친 이름"}
assert _post(client, "/api/m02/df/save", {"logic": renamed})["key"] == "DF-0001"
got = client.get("/api/m02/df/get", params={"key": "DF-0001"}).json()["logic"]
assert (
got["name"] == "고친 이름" and got["boxes"][3]["func"]["sheet"]["칸"]["A1"]["값"] == price
)
_post(client, "/api/m02/df/delete", {"key": "DF-0002"})
assert client.get("/api/m02/df/get", params={"key": "DF-0002"}).status_code == 404
_post(client, "/api/m02/df/save", {"logic": _logic(labor, "DF-0009")}, want=404)
_post(client, "/api/m02/df/save", {"logic": {**_logic(labor), "name": " "}}, want=400)
_post(client, "/api/m02/df/save", {"logic": {"name": "x"}}, want=400)
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로
assert sorted(p.name for p in cf.DATA.glob("*.json")) == ["DF-0001.json"]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_빈_입력_값은_쉬운_말(client: TestClient) -> None:
labor, _ = _labor()
logic = _logic(labor)
logic["boxes"][0]["input"]["elements"][0]["ref"] = "LB999999"
v = _post(client, "/api/m02/df/save", {"logic": logic})["values"]
assert v["errors"] and "석공" in v["errors"][0]
def test_입력_값_그룹마다(client: TestClient) -> None:
labor, price = _labor()
mat = _first(
"재료_자재품목.json",
lambda r: isinstance(r.get("물가자료"), (int, float)) and r.get("구분"),
)
word = f"{mat['이름']} {mat['규격'] or ''}".strip()
cond = {"구분": mat["구분"], "상세구분": mat["상세구분"], "검색어": word}
line = {"종류": "재료", "요소": cond, "이름": "재료", "단위": mat["단위"], "수량": "1",
"비목": "재료비"} # fmt: skip
row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체",
"입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip
want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"]
oil = _first("재료_유가전력.json", lambda r: isinstance(r.get("값"), (int, float)))
fx = _first("환율_한국은행환율.json", lambda r: isinstance(r.get("값"), (int, float)))
rate = _first("요율_조달청제비율.json", lambda r: isinstance(r.get("값"), dict))
eq = _first("기계.json", lambda r: isinstance((r.get("값") or {}).get("가격"), (int, float)))
local = [{"id": "UE-0007", "group": "재료", "name": "현장 자재", "spec": "", "unit": "개",
"value": 500}] # fmt: skip
items = [
{"id": "l", "group": "인력", "ref": labor},
{
"id": "m",
"group": "재료",
"ref": json.dumps(cond, ensure_ascii=False),
"unit": mat["단위"],
},
{"id": "o", "group": "유가전력", "ref": oil["키"]},
{"id": "f", "group": "환율", "ref": fx["키"]},
{"id": "r", "group": "요율", "ref": rate["키"]},
{"id": "e", "group": "기계", "ref": eq["키"], "field": "가격"},
{"id": "e2", "group": "기계", "ref": eq["키"]},
{"id": "t", "group": "소요량", "table": {"key": SAMPLE}},
{"id": "t1", "group": "소요량", "table": {"key": SAMPLE, "row": 1}},
{"id": "u", "group": "재료", "ref": "UE-0007"},
{"id": "w", "group": "인력", "ref": oil["키"]},
{"id": "z", "group": "모름", "ref": ""},
]
body = {"items": items, "userElements": local}
got = {v["id"]: v for v in _post(client, "/api/m02/df/values", body)["values"]}
assert _d(got["l"]["value"]) == price
assert got["m"]["value"] == want # 검색어 묶음 = 기존 /calc 재료 줄 단가
assert got["o"]["value"] == oil["값"] and got["f"]["value"] == fx["값"]
assert got["r"]["value"] == rate["값"]["rate_percent"] and got["r"]["unit"] == "%"
assert got["e"]["value"] == eq["값"]["가격"]
assert got["t"]["rows"][0] == HEAD
assert got["t1"]["rows"] == [HEAD, ["견치돌", 4, 4, 0.7, None, None]]
assert got["u"]["value"] == 500 and got["u"]["unit"] == "개"
for k in ("w", "z", "e2"): # 기계 묶음은 칸을 골라야 함
assert got[k]["ok"] is False and got[k]["reason"], got[k]
def test_사용자_요소_만들기_지우기(client: TestClient, folder: Path) -> None:
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
url = "/api/m02/df/user-elements"
a = _post(client, url, {"op": "add", "element": {"name": "가", "group": "재료", "value": 1.5}})
b = _post(client, url, {"op": "add", "element": {"name": "나", "unit": "회"}})
assert [a["element"]["id"], b["element"]["id"]] == ["UE-0001", "UE-0002"]
assert b["element"]["group"] == "재료" and b["element"]["value"] is None
_post(client, url, {"op": "add", "element": {"name": " "}}, want=400)
_post(client, url, {"op": "add", "element": {"name": "다", "value": "x"}}, want=400)
_post(client, url, {"op": "add", "element": {"name": "라", "group": "모름"}}, want=400)
_post(client, url, {"op": "delete", "id": "UE-0001"})
_post(client, url, {"op": "delete", "id": "UE-0001"}, want=404)
assert [e["id"] for e in client.get(url).json()["elements"]] == ["UE-0002"]
got = _post(
client, "/api/m02/df/values", {"items": [{"id": "x", "group": "재료", "ref": "UE-0002"}]}
)
assert got["values"][0]["ok"] is False # 값이 비어 있으면 쉬운 말
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터에 안 씀
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
cf.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
assert client.get("/api/m02/df/list").status_code == 403
assert client.post("/api/m02/df/values", json={"items": []}).status_code == 403
assert client.get("/api/m02/df/user-elements").status_code == 403
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
assert [x["key"] for x in client.get("/api/m02/df/list").json()["logics"]] == ["견본 돌쌓기"]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None:
"""입력 판(sub3) 기계 ref 「EQ키#칸」 · 물음 {ref, field} · 「시간당 경비」 = 기계경비 로직."""
logic = min(
(r for r in cf._machine_family() if "지역" in [i["이름"] for i in r["입력"]]),
key=cf._machine_rank,
)
code = str(cf._codes(logic)[0])
eq = _first("기계.json", lambda r: str(r.get("원문번호")) == code)
family = [r for r in cf._machine_family() if code in [str(c) for c in cf._codes(r)]]
chosen = min(family, key=cf._machine_rank)
given = {
i["이름"]: (code if i["이름"] == "기계" else (i.get("고르기") or [None])[0])
for i in chosen["입력"]
}
want = _post(client, "/api/m01/calc", {"key": chosen["키"], "inputs": given})["sums"]["계"]
items = [
{"id": "p", "group": "기계", "ref": f"{eq['키']}#가격"},
{"id": "p2", "group": "기계", "ref": eq["키"], "field": "가격"},
{"id": "h", "group": "기계", "ref": f"{eq['키']}#시간당 경비"},
{"id": "h2", "group": "기계", "ref": eq["키"], "field": "시간당 경비"},
{"id": "x", "group": "기계", "ref": f"{eq['키']}#없는칸"},
]
got = {v["id"]: v for v in _post(client, "/api/m02/df/values", {"items": items})["values"]}
assert got["p"]["value"] == got["p2"]["value"] == eq["값"]["가격"]
assert got["h"]["value"] == got["h2"]["value"] == want and got["h"]["unit"] == "원/시간"
assert got["x"]["ok"] is False and "없는칸" not in got["x"].get("value", "")
# 저장 — 입력 상자 요소 ref 그대로(「EQ키#칸」)
box = _box("m1", "input", "기계", input={"mode": "group", "group": "기계", "variables": [],
"elements": [{"ref": f"{eq['키']}#시간당 경비", "name": "기계 · 시간당 경비",
"spec": "", "unit": "원/시간"}]}) # fmt: skip
f1 = _box("f1", "function", "곱", func=_func(
{"id": "s1", "이름": "곱", "칸": {"B1": {"식": "A1*2"}}},
[{"port": "단가", "cell": "A1"}],
{"cell": "B1", "name": "경비", "unit": "원"})) # fmt: skip
o1 = _box("o1", "output", "경비", output={"cost": "경비", "unit": "원"})
body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1],
"links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"),
_link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip
saved = _post(client, "/api/m02/df/save", {"logic": body})
assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want)
_post(client, "/api/m02/df/delete", {"key": saved["key"]})
+275
View File
@@ -0,0 +1,275 @@
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
(Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만
· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
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
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]
# 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),
)
)
@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"] == BOX_COLOR[b["kind"]]
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 ("노무비", "재료비", "경비", "값")
units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []}
if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙)
assert b["unit"] == next(iter(units)), b["id"]
for link in lg["links"]:
a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]]
assert link["from"]["name"] in _ports_out(a), link
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_견본_표_그룹이_마스터와_같음(master) -> None:
"""표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)"""
cm, files = master
group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip
for lg in SAMPLES.values():
for b in lg["boxes"]:
table = b.get("input", {}).get("table")
if table:
assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"])
def test_견본1_원가_calc_와_같음(master) -> None:
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
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
+80
View File
@@ -0,0 +1,80 @@
"""M02 단가산출 흐름도 — 단위 · 금액 표준 표(35-9 · 정함 11) 시험.
· `_standard/단위표준.json` — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」 표 줄 수 · 병합 칸 폄 · 「-」 바꾸기 · 주 ①~③
· `_standard/금액단위표준.json` — 「금액의 단위표준」 다섯 줄 · 미만버림
· 원문 줄 번호가 원문의 그 줄을 가리킴 · 원문 흠은 고치지 않음(사용자가 시험판에서 고침)
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
STD = ROOT / "M02_MasterTemplete" / "DataFlow" / "_standard"
SRC = (
ROOT
/ "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md"
)
UNIT = json.loads((STD / "단위표준.json").read_text(encoding="utf-8"))
MONEY = json.loads((STD / "금액단위표준.json").read_text(encoding="utf-8"))
LINES = SRC.read_text(encoding="utf-8").split("\n")
def _cells(no: int) -> list[str]:
return [c.strip() for c in LINES[no - 1].strip().strip("|").split("|")]
def test_단위표준_줄_수_병합_원문줄() -> None:
rows = UNIT["줄"]
# 원문 표 = 머리 · 가름 · 몸 1행(단위 · 소수자리) 다음 73 줄 — 병합 칸 10 줄(주석의 14~15 · 17~20 · 32~33 · 35~36 · 45~47 · 61~63)
assert len(rows) == 73
assert sum(1 for r in rows if r.get("병합")) == 10
spread = {r["종목"] for r in rows if r.get("병합")}
assert spread == {
"견치돌, 깬돌",
"돌쌓기 및 돌붙임",
"목재(판재)",
"말뚝",
"돌망태",
"관류(管類)",
}
for r in rows:
cells = _cells(r["원문 줄"])
assert cells[0] == ("" if r.get("병합") else r["종목"])
assert [r["종목"] for r in rows[:3]] == ["공사연장", "공사폭원", "직공인부"]
assert rows[-1]["종목"] == "마대"
def test_단위표준_바꾸기() -> None:
"""소수자리 「-」 = 0 · 단위 「-」 = 「없음」 · 빈 칸 = null · 원문 글자는 그대로"""
for r in UNIT["줄"]:
src = _cells(r["원문 줄"])
for i, key in [(2, "규격 소수자리"), (4, "단위수량 소수자리")]:
want = 0 if src[i] == "-" else (int(src[i]) if src[i] else None)
assert r[key] == want, (r["원문 줄"], key)
for i, key in [(1, "규격 단위"), (3, "단위수량 단위")]:
want = "없음" if src[i] == "-" else (src[i] or None)
assert r[key] == want, (r["원문 줄"], key)
공사연장 = UNIT["줄"][0]
assert (공사연장["규격 단위"], 공사연장["규격 소수자리"], 공사연장["단위수량 소수자리"]) == (
"m",
2,
0,
)
말뚝 = [r for r in UNIT["줄"] if r["종목"] == "말뚝"]
assert 말뚝[1]["단위수량 단위"] is None # 원문 빈 칸 그대로
assert [n[:1] for n in UNIT["주"]] == ["①", "②", "③"]
def test_금액_다섯_줄() -> None:
rows = MONEY["줄"]
assert [(r["종목"], r["지위"]) for r in rows] == [
("설계서의 총액", 1000),
("설계서의 소계", 1),
("설계서의 금액란", 1),
("일위대가표의 계금", 1),
("일위대가표의 금액란", 0.1),
]
assert all(r["단위"] == "원" and r["비고"] == "미만버림" for r in rows)
assert all(_cells(r["원문 줄"])[0] == r["종목"] for r in rows)