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

This commit is contained in:
2026-10-01 16:02:21 +09:00
18 changed files with 1212 additions and 207 deletions
+3 -2
View File
@@ -17,8 +17,9 @@ import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model";
export type { SlField };
/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` */
export type SlQuery = { id: string } & SlSource;
/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` · 자재 `key` = 키 또는 검색어 묶음 JSON 글
* (`{구분, 상세구분, 검색어, 규격?}`) · `unit` = 그 줄 단위(주면 기존 로직 재료 줄과 같은 단가) */
export type SlQuery = { id: string; unit?: string } & SlSource;
/** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */
export type SlOutside = SlQuery;
+3 -3
View File
@@ -52,10 +52,10 @@
display: none;
}
/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */
/* 띠 — 세로는 상자 높이만큼 다 보임(사용자 요청) · 길면 가로로 밀기 */
.m01-sldg__band {
max-height: 33vh;
overflow: auto;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
+5 -10
View File
@@ -8,9 +8,9 @@
font-size: var(--text-body);
}
/* 세 칸은 내용 높이만큼 — 넘치면 창(모달) 전체만 세로로 밈 */
.m01-sl .ui-modal__body {
flex: 1;
min-height: 0;
flex: 0 0 auto;
gap: var(--spacing-12);
}
@@ -111,11 +111,10 @@
/* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */
.m01-sl__layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: minmax(160px, 2fr) minmax(0, 3fr);
grid-template-rows: auto auto;
align-items: start;
grid-template-areas:
"diagram diagram"
"panel result";
@@ -137,7 +136,6 @@
.m01-sl__part {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
@@ -149,9 +147,7 @@
}
.m01-sl__part-body {
flex: 1;
min-height: 0;
overflow: auto;
min-width: 0;
padding: var(--spacing-12);
}
@@ -164,7 +160,6 @@
"diagram"
"panel"
"result";
overflow: auto;
}
}
@@ -33,7 +33,8 @@ export const loadMods = () =>
import("./M01_SL_Result"),
import("./M01_SL_Import"),
import("../../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc]) => ({
import("./M01_SL_LogicTools"),
]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc, logic]) => ({
model,
ev,
api,
@@ -44,6 +45,7 @@ export const loadMods = () =>
result,
imp,
recalc,
logic,
}));
export type Mods = Awaited<ReturnType<typeof loadMods>>;
@@ -109,13 +111,19 @@ export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] {
if (!at.has(k)) at.set(k, p);
};
M.item.itemOutsides(sl).forEach(put);
for (const c of M.ev.outsideCells(sl)) {
const node = sl.nodes.find((n) => n.id === c.node);
if (node && c.source)
put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell });
}
// 상자에 출처가 적힌 알약 → 넘길 값을 실은 물음(outsideOf) → 출처를 짐작한 나머지(넘길 값 없음) 차례
const cells = M.ev.outsideCells(sl);
const fromCell = (explicit: boolean): void => {
for (const c of cells) {
const node = sl.nodes.find((n) => n.id === c.node);
if (node && c.source && !!node.source === explicit)
put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell });
}
};
fromCell(true);
// 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0)
M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put);
fromCell(false);
return [...at.values()];
}
@@ -139,6 +147,18 @@ export async function fillOutside(
return { sl: M.ev.applyOutside(sl, values), errors };
}
/* 시트 판 높이 — 그 시트가 쓰는 줄 수 + 두 줄(안쪽 세로 밀기 없게 · 사용자 요청)
* ponytail: 편집기 줄 높이 · 띠 높이를 숫자로 둠 — 편집기 꾸밈이 바뀌면 같이 고침 */
const ROW_PX = 20;
const CHROME_PX = 26 + 24 + 24 + 20; // 위 띠 · 칸 머리 · 아래 상태줄 · 가로 밀기 막대
function sheetHeight(sheet: { 칸: Record<string, unknown> } | null): number {
const rows = Object.keys(sheet?.칸 ?? {}).reduce(
(most, a1) => Math.max(most, Number(a1.replace(/^[A-Z]+/, "")) || 0),
0,
);
return CHROME_PX + (Math.max(rows, 6) + 2) * ROW_PX;
}
/** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */
function serverTotal(sl: SheetLogic): number | null {
const box = sl.nodes.find((n) => n.kind === "result");
@@ -293,6 +313,7 @@ export function showPaper(
panelHost.hidden = false;
pickHint.hidden = true;
toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움
panelHost.style.setProperty("--sl-sheet-h", `${sheetHeight(M.model.nodeSheet(sl, node.id))}px`);
M.panel.renderPanel(panelHost, sl, node.id, {
change: (next) => edit(next),
pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)),
@@ -306,6 +327,13 @@ export function showPaper(
loadPrices(node.id);
},
});
else if (node.kind === "logic")
M.logic.renderLogicTools(toolsHost, sl, node.id, {
change: (next) => {
edit(next, true);
loadPrices(node.id);
},
});
else toolsHost.replaceChildren();
};
+140 -137
View File
@@ -19,15 +19,15 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
import type {
CalcValues,
Cell,
DataValidationKind,
Sheet,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { readLogic } from "../LogicBuilder/M01_LB_Blocks";
import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model";
import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api";
import { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item";
import {
parseFormula,
RULES,
@@ -64,6 +64,7 @@ const TEXT = {
Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"],
FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"],
Parse: ["읽을 수 없는 글", "Text that can't be read"],
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
} as const;
const t = (k: keyof typeof TEXT, v = ""): string =>
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
@@ -75,6 +76,8 @@ export interface SlImportOptions {
tables?: Record<string, SlImportTable>;
/** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */
examples?: Record<string, number | string>;
/** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */
names?: Record<string, string>;
}
export interface SlImportReport {
@@ -120,20 +123,26 @@ class Build {
readonly tables = new Map<string, TableBox>();
readonly logics = new Map<string, SlNode>();
readonly consts = new Map<string, SlNode>();
readonly specs = new Map<string, LogicInput>();
/** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */
readonly names = new Map<string, string>();
constructor(readonly opts: SlImportOptions) {}
constructor(readonly opts: SlImportOptions) {
for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v);
}
node(kind: SlKind, name: string, stage: number, ref?: string): SlNode {
/** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */
node(kind: SlKind, name: string, stage: number, ref?: string, head = true): SlNode {
const n = this.nodes.length + 1;
const head = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore
const base = kind === "result" ? t("Result") : `${t(head)} · ${name}`.slice(0, 31);
const word = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore
const base = kind === "result" ? t("Result") : `${t(word)} · ${name}`.slice(0, 31);
let title = base;
for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++)
title = `${base.slice(0, 28)} ${k}`;
const sheet: Sheet = {
id: `s${n}`,
이름: title,
칸: { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } },
칸: head ? { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } } : {},
};
this.book.시트.push(sheet);
this.sheets.set(sheet.id, sheet);
@@ -306,7 +315,7 @@ function put(sc: Scope, label: string, text: string, pill: "out" | null, note =
function constBox(b: Build, key: string): SlNode {
const had = b.consts.get(key);
if (had) return had;
const node = b.node("const", key, 0, key);
const node = b.node("const", b.names.get(key) ?? key, 0, key);
const at = b.row(node, t("Price"), {}, t("Outside"));
b.pill(node, "in", { name: t("Price"), cell: at, outside: true });
b.pill(node, "out", { name: t("Price"), cell: at });
@@ -340,16 +349,17 @@ function logicBox(b: Build, n: Extract<Ast, { t: "logic" }>, field?: string): Sl
let node = b.logics.get(sig);
if (!node) {
if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head));
node = b.node("logic", n.head, 2, n.head);
node = b.node("logic", b.names.get(n.head) ?? n.head, 1, n.head);
args(b, node, n.args);
b.logics.set(sig, node);
}
const name = field ?? t("Result");
if (!node.pillsOut.some((p) => p.name === name)) {
const at = b.row(node, name, {}, t("Outside"));
b.pill(node, "in", { name, cell: at, outside: true });
b.pill(node, "out", { name, cell: at });
}
const money = ["노무비", "재료비", "경비", "계"];
for (const name of field && money.includes(field) ? money : [field ?? t("Result")])
if (!node.pillsOut.some((p) => p.name === name)) {
const at = b.row(node, name, {}, t("Outside"));
b.pill(node, "in", { name, cell: at, outside: true });
b.pill(node, "out", { name, cell: at });
}
return node;
}
@@ -374,7 +384,7 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k));
if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule))
throw new Fail(t("Rule", String(table.범위규칙)));
const node = b.node("table", table.이름 || n.head, 1, n.head);
const node = b.node("table", table.이름 || b.names.get(n.head) || n.head, 1, n.head);
const s = b.sheet(node);
// 조건 알약 — 받는 값은 넘긴 식
const cond = new Map<string, string>();
@@ -383,7 +393,6 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
// 표 E1 부터
const cols = tableCols(table);
const rows = tableRows(table);
b.row(node, t("Table"), { 값: n.head }, t("Outside"));
// 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움
b.pill(node, "out", { name: "표", cell: "E1", outside: true });
rows.forEach((r, i) =>
@@ -394,8 +403,13 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
);
const letter = (key: string, part: "v" | "lo" | "hi"): string =>
colName(4 + cols.findIndex((c) => c.key === key && c.part === part));
const N = table.줄.length;
// 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 20 줄까지 셈 · 「표 줄 수」 칸 밖 줄은 안 맞음
const N = Math.max(20, table.줄.length);
const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`;
const count = b.row(node, "표 줄 수", {
식: `MAX(${cols.map((_, j) => `COUNTA(${range(colName(4 + j))})`).join(",")})`,
});
const last = `$B$${count.slice(1)}`;
let free = 4 + cols.length + 1;
// 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로)
const M = colName(free++);
@@ -424,7 +438,8 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
: `OR(${H}="",${x}<=${H})`;
return `AND(OR(${L}="",${x}${low}${L}),${up})`;
});
s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" };
const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1";
s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` };
}
// 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`)
let hit = M;
@@ -484,27 +499,14 @@ function inputBox(b: Build, spec: LogicInput): void {
const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? "");
const r = Number(at.slice(1)) - 1;
const whole = { r0: r, c0: 1, r1: r, c1: 1 };
if (spec.고르기?.length)
b.sheet(node).유효성 = [
{
id: "v1",
범위: [whole],
규칙: { 종류: "목록", 값들: spec.고르기.map(String) },
어기면: "막음",
},
];
else if (spec.범위)
b.sheet(node).유효성 = [
{
id: "v1",
범위: [whole],
규칙: { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] },
어기면: "막음",
},
];
const rule: DataValidationKind | null = spec.고르기?.length
? { 종류: "목록", 값들: spec.고르기.map(String) }
: spec.범위 ? { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] } : null; // prettier-ignore
if (rule) b.sheet(node).유효성 = [{ id: "v1", 범위: [whole], 규칙: rule, 어기면: "막음" }];
const pill = node.pillsOut[0];
if (spec.단위) pill.unit = spec.단위;
b.inputs.set(spec.이름, node);
b.specs.set(spec.이름, spec);
}
/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */
@@ -519,54 +521,90 @@ function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | numbe
return [node, label];
}
/** 항목 상자(호표 줄) → 비목 출력 알약들 */
/** 항목 틀 — Item `applyItemLayout` 으로 그 상자 시트 · 알약을 채움(Item 이 알아보는 꼴 한 벌) */
function layout(b: Build, node: SlNode, ref: ItemRef, qty?: number): void {
const sheet = b.sheet(node);
const mini = { key: null, name: "", resultUnit: "", workbook: { ...b.book, 시트: [sheet] }, nodes: [node], edges: [] }; // prettier-ignore
const got = applyItemLayout(mini, node, ref, qty);
sheet.칸 = got.workbook.시트[0].칸;
const 유효성 = got.workbook.시트[0].유효성;
if (유효성) sheet.유효성 = 유효성;
Object.assign(node, got.nodes[0]);
}
/** 항목 상자(호표 줄) — Item 틀 그대로(node.ref = ItemRef) → 비목 출력 알약들 */
function itemBox(b: Build, item: HoLine, lines: Map<string, [SlNode, string]>): [SlNode, Cost][] {
const name = item.이름 || String(item.요소);
const element = typeof item.요소 === "string" ? item.요소 : JSON.stringify(item.요소);
const call =
typeof item.요소 === "string" && item.요소.startsWith("로직(") ? parseFormula(item.요소) : null;
const node = b.node("item", name, 3, call?.t === "logic" ? (call.args.some(([k]) => k === "기계") ? element : call.head) : element); // prettier-ignore
const q = qtyFrom(b, item, name);
const qty =
typeof q === "number"
? b.row(node, t("Qty"), { 값: q }, item.단위 ?? "")
: b.link(node, q[0], q[1]);
if (typeof q === "number") b.pill(node, "in", { name: t("Qty"), cell: qty, unit: item.단위 });
const out: [SlNode, Cost][] = [];
if (call?.t === "logic") {
args(b, node, call.args);
const unit = COSTS.map((k) => {
const at = b.row(node, `${t("Price")} · ${k}`, {}, t("Outside"));
b.pill(node, "in", { name: k, cell: at, outside: true });
return at;
});
const fixed = item.비목 as Cost | undefined;
const total = `${qty}*(${unit.join("+")})`;
if (fixed && (COSTS as readonly string[]).includes(fixed)) {
b.out(node, fixed, { 식: total });
out.push([node, fixed]);
lines.set(name, [node, fixed]);
} else {
COSTS.forEach((k, i) => {
b.out(node, k, { 식: `${qty}*${unit[i]}` });
out.push([node, k]);
});
b.out(node, t("Amount"), { 식: total });
lines.set(name, [node, t("Amount")]);
const el: unknown = item.요소;
const call = typeof el === "string" && el.startsWith("로직(") ? parseFormula(el) : null;
const logic = call?.t === "logic" ? call : null;
const what: ItemRef["what"] = logic
? logic.args.some(([k]) => k === "기계") ? "장비" : "일위대가"
: item.종류 === "인력" ? "노무" : "재료"; // prettier-ignore
// 넘길 값 — 입력값 이름은 예시값으로(Item 이 ref.inputs 로 바깥 값을 받음) · 글 · 수는 요소 글 안에 그대로
const need: LogicInput[] = [];
const inputs: Record<string, unknown> = {};
const bad: string[] = [];
for (const [k, v] of logic?.args ?? []) {
if (v.t !== "name") {
if (v.t !== "num" && v.t !== "str") bad.push(k);
continue;
}
const spec = b.specs.get(v.v);
if (!spec) bad.push(k);
else {
need.push({ ...spec, 이름: k });
inputs[k] = example(spec, b.opts.examples?.[spec.이름]).값;
}
return out;
}
const price = b.row(node, t("Price"), {}, t("Outside"));
b.pill(node, "in", { name: t("Price"), cell: price, outside: true });
const holes = typeof item.요소 === "string" ? [item.요소] : Object.values(item.요소 ?? {});
const element = what === "재료" && typeof el === "string" ? { 키: el } : el;
const ref: ItemRef = {
what,
element,
...(item.규격 ? { spec: item.규격 } : {}),
...(item.단위 ? { unit: item.단위 } : {}),
...(need.length ? { need, inputs } : {}),
};
const node = b.node("item", name, 3, JSON.stringify(ref), false);
const outside: Cost[] = what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : [...COSTS];
const q = qtyFrom(b, item, name);
layout(b, node, ref, typeof q === "number" ? q : undefined);
if (typeof q !== "number") b.link(node, q[0], q[1], node.pillsIn[0]);
const holes = typeof el === "string" ? [el] : Object.values(el ?? {});
const priceAt = what === "재료" ? "B3" : "B2";
if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v)))
b.cant(node, price, t("Hole", element), element);
b.cant(node, priceAt, t("Hole", typeof el === "string" ? el : JSON.stringify(el)), String(el));
if (bad.length) b.cant(node, priceAt, t("PassCalc"), bad.join(", "));
lines.set(name, [node, "금액"]);
return outside.map((c): [SlNode, Cost] => [node, c]);
}
/** 덧줄 — 「노무비 * 3 / 100」 꼴이면 Item 비율 틀 · 아니면 식 그대로 계산하는 항목(금액 · 비목 · 단가 알약) */
const RATE_RE = /^\(?(노무비|재료비|경비)\)?\*(\d+(?:\.\d+)?)\/100$/;
function extraBox(
b: Build,
extra: { 이름: string; 식: string; 비목?: string; 출처?: string },
sc: Omit<Scope, "here">,
): [SlNode, Cost] {
const cost = (
(COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비"
(COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비"
) as Cost;
b.out(node, cost, { 식: `${qty}*${price}` });
lines.set(name, [node, cost]);
return [[node, cost]];
const m = RATE_RE.exec(extra.식.replace(/\s+/g, ""));
if (m) {
const base = m[1] as Cost;
const ref: ItemRef = { what: "비율", base, into: cost };
const node = b.node("item", extra.이름, 3, JSON.stringify(ref), false);
b.sheet(node).칸.B1 = { 값: Number(m[2]) }; // 율 — Item 이 이 칸을 살림
layout(b, node, ref);
return [node, cost];
}
const node = b.node("item", extra.이름, 3);
const at = put({ ...sc, here: node }, "금액", extra.식, "out", extra.출처 ?? "");
b.out(node, "단가", { 식: at });
b.out(node, cost, { 식: at });
b.out(node, "사용 수량", { 값: 1 });
return [node, cost];
}
type Cut = { 대상?: string; 자리?: number; 방법?: string };
@@ -598,8 +636,10 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl
b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 });
} else {
const lines = new Map<string, [SlNode, string]>();
for (const h of row.호표 ?? [])
if (typeof h.요소 === "string" && h.이름 && !b.names.has(h.요소)) b.names.set(h.요소, h.이름);
const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines));
// 항목 합 — 덧줄이 「노무비」 등으로 부르는 호표 줄 비목 합
// 항목 합 — 식 덧줄이 「노무비」 등으로 부르는 앞 줄까지의 비목 합
const sum = b.node("calc", t("Sum"), 3);
const costs = {} as Record<Cost, [SlNode, string][]>;
for (const k of COSTS) {
@@ -608,27 +648,32 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl
costs[k] = [[sum, k]];
}
const extras = new Map<string, [SlNode, string]>();
const into: [SlNode, Cost][] = [...outs];
for (const extra of row.덧줄 ?? []) {
const cost = (
(COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비"
) as Cost;
const node = b.node("item", extra.이름, 3, "비율");
put({ b, here: node, costs, extras, lines }, cost, extra.식, "out", extra.출처 ?? "");
const [node, cost] = extraBox(b, extra, { b, costs, extras, lines });
extras.set(extra.이름, [node, cost]);
costs[cost] = [...costs[cost], [node, cost]];
into.push([node, cost]);
}
const total: string[] = [];
// 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계
for (const k of COSTS) {
const refs = costs[k].map(([n, c]) => b.link(result, n, c));
const at = b.out(result, k, {
식: cutText(row, "계", refs.length > 1 ? `SUM(${refs.join(",")})` : refs[0]),
});
total.push(at);
const at = b.row(result, k, {});
b.pill(result, "in", { name: k, cell: at });
const refs = into
.filter(([, c]) => c === k)
.map(([n]) => {
const src = n.pillsOut.find((p) => p.name === k)!;
const to = { node: result.id, pill: k };
b.edges.push({ id: `e${b.edges.length + 1}`, from: { node: n.id, pill: k }, to });
return `${quoteSheet(b.sheet(n).이름)}!${src.cell}`;
});
if (refs.length) b.sheet(result).칸[at] = { 식: refs.join("+") };
}
b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위);
const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`));
b.out(result, "계", { 식: parts.join("+") }, row.결과단위);
}
b.book.활성 = b.book.시트[0]?.id;
const sl: SheetLogic = {
const built: SheetLogic = {
key: null,
name: row.이름,
resultUnit: row.결과단위,
@@ -636,6 +681,8 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl
nodes: b.nodes,
edges: b.edges,
};
// 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께)
const sl = refreshBases(built);
return { sl, problems: b.problems };
}
@@ -644,48 +691,4 @@ export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLog
return importLogicReport(row, opts).sl;
}
/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */
function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown {
const got = calc?.[sheetId]?.[cell];
if (got && "수" in got) return Number(got.수);
if (got && "글" in got) return got.글;
if (got && "참" in got) return got.참;
return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null;
}
/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */
export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] {
const out: SlOutsideAt[] = [];
for (const node of sl.nodes) {
const passed: Record<string, unknown> = {};
const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside);
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
for (const p of node.pillsIn) {
const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값;
if (node.kind !== "table" && TEXT.Pass.includes(note as never))
passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc);
}
const ref = node.ref ?? "";
for (const p of outside) {
const id = `${node.id}:${p.name}`;
const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name)
? (p.name as SlField)
: undefined;
let item: SlOutside;
if (node.kind === "table") item = { id, kind: "표", table: ref };
else if (
node.kind === "logic" ||
ref.startsWith("GF") ||
ref.startsWith("GC") ||
ref.startsWith("GX")
)
item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) };
else if (ref.startsWith("로직("))
item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) };
else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref };
else item = { id, kind: "자재", key: ref };
out.push({ item, sheet: node.sheetId, cell: p.cell });
}
}
return out;
}
export { outsideOf } from "./M01_SL_Import_Parse";
@@ -3,10 +3,15 @@
* 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로
* 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 ·
* - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null
* 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음
* 아래 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸 — 바깥 값 「표」 모양과 같음) ·
* 바깥 값 자리(`outsideOf`)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { CalcValues } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { SheetLogic, SlNode } from "./M01_SL_Model";
import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api";
import { itemRefOf, type ItemRef } from "./M01_SL_Item";
export type Ast =
| { t: "num"; v: string }
@@ -184,3 +189,85 @@ export function tableRows(table: SlImportTable): (string | number | null)[][] {
};
return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))];
}
/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */
/** 넘길 값 칸 표시(C 칸) — `M01_SL_Import` TEXT.Pass 한 · 영 */
const PASS = ["넘길 값", "Passed value"];
const COST3 = ["노무비", "재료비", "경비"] as const;
/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */
function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown {
const got = calc?.[sheetId]?.[cell];
if (got && "수" in got) return Number(got.수);
if (got && "글" in got) return got.글;
if (got && "참" in got) return got.참;
return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null;
}
/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */
export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] {
const out: SlOutsideAt[] = [];
for (const node of sl.nodes) {
const passed: Record<string, unknown> = {};
const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside);
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
for (const p of node.pillsIn) {
const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값;
if (node.kind !== "table" && PASS.includes(String(note)))
passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc);
}
const ref = node.ref ?? "";
const item = itemRefOf(node);
if (item) {
out.push(...itemOutside(node, item));
continue;
}
for (const p of outside) {
const id = `${node.id}:${p.name}`;
const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name)
? (p.name as SlField)
: undefined;
let item: SlOutside;
if (node.kind === "table") item = { id, kind: "표", table: ref };
else if (
node.kind === "logic" ||
ref.startsWith("GF") ||
ref.startsWith("GC") ||
ref.startsWith("GX")
)
item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) };
else if (ref.startsWith("로직("))
item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) };
else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref };
else item = { id, kind: "자재", key: ref };
out.push({ item, sheet: node.sheetId, cell: p.cell });
}
}
return out;
}
/** 항목 상자 바깥 값 — Item `itemOutsides` 와 같은 칸 · 자재 검색어 묶음은 key = 묶음 JSON 글(서버가 「{」 로 풂) */
function itemOutside(node: SlNode, ref: ItemRef): SlOutsideAt[] {
const at = (cell: string, item: SlOutside): SlOutsideAt => ({ item, sheet: node.sheetId, cell });
const id = (f: string): string => `${node.id}:${f}`;
const el = ref.element;
const inputs = ref.inputs ?? {};
if (ref.what === "노무") return [at("B2", { id: id("노무비"), kind: "노임", key: String(el) })];
if (ref.what === "재료") {
const keyed = typeof el === "object" && el !== null && "키" in el;
const key = keyed ? String((el as { 키: unknown }).키) : JSON.stringify(el);
return [at("B3", { id: id("재료비"), kind: "자재", key })];
}
if (ref.what === "비율") return [];
const text = String(el);
const key = /로직\(\s*([A-Z]{2}\d{6})/.exec(text)?.[1] ?? "";
return COST3.map((f, i) =>
at(
`B${2 + i}`,
ref.what === "장비"
? { id: id(f), kind: "장비", element: text, inputs, field: f }
: { id: id(f), kind: "로직", key, inputs, field: f },
),
);
}
+33 -28
View File
@@ -10,7 +10,6 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { LogicInput } from "../M01_MasterData_UI_Logic_Api";
import { searchElements } from "../M01_MasterData_UI_Logic_Api";
@@ -18,7 +17,7 @@ import { formatPrice } from "../M01_MasterData_UI_Logic_Money";
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model";
import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api";
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
import { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model";
import "./M01_SL_Item.css";
const TEXT = {
@@ -265,6 +264,16 @@ const FIELD_CELL: Record<string, string> = {
};
/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */
/** 상자 시트를 항목 틀로 채움 — 가져오기(Import)도 이 틀 · ref 로 맞춤 */
export function applyItemLayout(
sl: SheetLogic,
node: SlNode,
ref: ItemRef,
qty?: number | null,
): SheetLogic {
return applyLayout(sl, node, ref, qty);
}
function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic {
const sheet = sheetOf(sl, node);
const oldRef = itemRefOf(node);
@@ -299,39 +308,35 @@ function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number |
};
}
/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */
/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(Model `connect`)로 함
* · 아는 항목 상자(ref 가 이 틀 것)만 바탕이 됨 — 모르는 상자(가져온 것 등)는 읽지도 쓰지도 않음
* · 비목을 바꾸면 옛 비목 선만 끊음 — 손으로 이은 다른 선 · 그 칸 수식은 그대로 */
export function refreshBases(sl: SheetLogic): SheetLogic {
let out = sl;
for (const node of sl.nodes) {
const ref = itemRefOf(node);
if (!ref || ref.what !== "비율") continue;
if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue;
const base = ref.base ?? "노무비";
const sources = out.nodes.filter((n) => {
const known = (n: SlNode): boolean => {
const r = itemRefOf(n);
return n.kind === "item" && r && r.what !== "비율";
});
const refs = sources
.map((n) => {
const s = sheetOf(out, n);
return s ? `${quoteSheet(s.이름)}!${costCell(itemRefOf(n)!, base)}` : "";
})
.filter(Boolean);
const formula = refs.length ? refs.join("+") : "0";
out = editSheet(out, node, (cells) => {
cells[RATE.base] = refs.length ? calc(formula) : num(0);
});
const mine = (id: string): boolean => id.startsWith(`e-base-${node.id}-`);
out = {
...out,
edges: [
...out.edges.filter((e) => !mine(e.id)),
...sources.map((n) => ({
id: `e-base-${node.id}-${n.id}`,
from: { node: n.id, pill: base },
to: { node: node.id, pill: PILL_BASE },
})),
],
return n.id !== node.id && n.kind === "item" && !!r && r.what !== "비율";
};
for (const e of out.edges.filter((x) => x.to.node === node.id && x.to.pill === PILL_BASE)) {
const from = out.nodes.find((n) => n.id === e.from.node);
if (from && known(from) && e.from.pill !== base && (COSTS as string[]).includes(e.from.pill))
out = disconnect(out, e.id);
}
for (const src of out.nodes.filter(known)) {
const there = out.edges.some(
(e) =>
e.from.node === src.id &&
e.from.pill === base &&
e.to.node === node.id &&
e.to.pill === PILL_BASE,
);
if (!there)
out = connect(out, { node: src.id, pill: base }, { node: node.id, pill: PILL_BASE }).sl;
}
}
return out;
}
@@ -0,0 +1,87 @@
.m01-sllogic__title {
color: var(--color-text-body);
font-size: var(--text-body);
}
.m01-sllogic__chosen {
margin: var(--spacing-4) 0;
color: var(--color-text-body);
font-weight: 600;
}
.m01-sllogic__search {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: var(--spacing-8) 0;
}
.m01-sllogic__input {
box-sizing: border-box;
width: 200px;
max-width: 100%;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m01-sllogic__input[readonly] {
background: var(--color-surface);
}
.m01-sllogic__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-height: 240px;
margin: 0 0 var(--spacing-8);
padding: 0;
overflow: auto;
list-style: none;
}
.m01-sllogic__list .ui-btn {
max-width: 100%;
text-align: left;
white-space: normal;
}
.m01-sllogic__fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16);
margin: var(--spacing-8) 0;
color: var(--color-text-body);
}
.m01-sllogic__fields > strong,
.m01-sllogic__fields > .m01-sllogic__muted {
flex-basis: 100%;
}
.m01-sllogic__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-sllogic__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-sllogic__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
@@ -0,0 +1,297 @@
/* =============================================================================
* M01_SL_LogicTools.ts
* 시트 로직 — 「다른 로직」 상자 손잡이. 기존 로직을 찾아 고르면 상자 시트를 그 로직의 틀로 채움
* 로직의 입력값마다 입력 알약(「넘길 값」 줄 · 예시값) · 결과는 바깥 값 출력 알약
* 돈 로직 = 노무비 · 재료비 · 경비 · 계 / 돈 아닌 로직(작업량 등) = 결과
* 틀 모양은 Import 의 다른 로직 상자와 한 벌(`outsideOf` 가 「넘길 값」 칸과 알약 이름으로 물음을 만듦)
* — 상자 `source` 는 안 씀: 한 상자에 비목별 물음이 여럿이라 알약 이름이 비목을 정함. 상자 `ref` = 로직 번호.
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import type { Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { listEasy, openEasy, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
import { slPlain } from "./M01_SL_Api";
import { disconnect, renameNode, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model";
import "./M01_SL_LogicTools.css";
const TEXT = {
Title: ["다른 로직", "Other logic"],
Search: ["로직 이름 찾기", "Find a logic by name"],
Find: ["찾기", "Find"],
None: ["아직 고르지 않았어요", "Nothing chosen yet"],
Chosen: ["고른 로직", "Chosen logic"],
NoHit: ["찾은 로직이 없어요", "No logic found"],
More: ["더 있어요 — 이름을 더 적어 보세요", "More exist — type more of the name"],
Loading: ["가져오는 중", "Loading"],
Inputs: ["넘길 값", "Passed values"],
NoInputs: ["넘길 값이 없는 로직이에요", "This logic takes no values"],
Linked: ["다른 상자에서 이어진 값", "Value from another box"],
Pick: ["고르세요", "Choose"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Hint: [
"결과는 바깥 값 — 열 때 · 저장 때 넣어요",
"Result is an outside value — filled on open and save",
],
Result: ["결과", "Result"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
Expense: ["경비", "Expense"],
Sum: ["계", "Total"],
Name: ["이름", "Name"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Pass: ["넘길 값", "Passed value"],
Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"],
} as const;
type TextKey = keyof typeof TEXT;
const tt = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const LIMIT = 30;
/** 로직 결과가 돈인가 */
const isMoney = (unit: string): boolean => unit.startsWith("원");
/** 상자 시트를 다른 로직 틀로 채움 · `trial` = 입력값 예시(열 때 받은 자동값) */
export function applyLogic(
sl: SheetLogic,
nodeId: string,
row: LogicRow,
trial: Record<string, unknown> = {},
): SheetLogic {
const node = sl.nodes.find((n) => n.id === nodeId);
if (!node) return sl;
if (node.ref === row.키 && node.pillsIn.length) return sl; // 같은 로직을 또 고름 — 그대로
// 다른 로직을 고르면 옛 선은 끊음(받는 칸 수식도 함께 정리)
let work = sl;
for (const e of sl.edges)
if (e.from.node === nodeId || e.to.node === nodeId) work = disconnect(work, e.id);
for (const name of [row.이름, `${row.이름} 2`, `${row.이름} 3`]) {
const got = renameNode(work, nodeId, name);
if (!got.why) {
work = got.sl;
break;
}
}
const cells: Record<string, Cell> = {
A1: { 값: tt("Name") },
B1: { 값: tt("Value") },
C1: { 값: tt("Note") },
};
const pillsIn: SlPill[] = [];
const pillsOut: SlPill[] = [];
let r = 2;
for (const spec of row.입력 ?? []) {
const at = `B${r}`;
cells[`A${r}`] = { 값: spec.이름 };
const v = trial[spec.이름];
if (typeof v === "number" || typeof v === "string") cells[at] = { 값: v };
cells[`C${r}`] = { 값: tt("Pass") };
pillsIn.push({ name: spec.이름, cell: at, ...(spec.단위 ? { unit: spec.단위 } : {}) });
r++;
}
const unit = row.결과단위 ?? "";
const money = isMoney(unit);
const outs = money ? [tt("Labor"), tt("Material"), tt("Expense"), tt("Sum")] : [tt("Result")];
for (const name of outs) {
const at = `B${r}`;
cells[`A${r}`] = { 값: name };
cells[`C${r}`] = { 값: tt("Outside") };
pillsIn.push({ name, cell: at, outside: true });
pillsOut.push({ name, cell: at, unit: money ? "원" : unit });
r++;
}
const 시트 = work.workbook.시트.map((s) => (s.id === node.sheetId ? { ...s, 칸: cells } : s));
return {
...work,
workbook: { ...work.workbook, 시트 },
nodes: work.nodes.map((n) => {
if (n.id !== nodeId) return n;
const next: SlNode = { ...n, ref: row.키, pillsIn, pillsOut, stage: 1 };
delete next.source;
return next;
}),
};
}
/** 그 로직의 입력 설명(고르기 · 범위) — 한 번 받은 것은 다시 안 받음 */
const rows = new Map<string, LogicRow>();
/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */
export function renderLogicTools(
host: HTMLElement,
sl: SheetLogic,
nodeId: string,
on: { change(sl: SheetLogic): void },
): void {
const node = sl.nodes.find((n) => n.id === nodeId);
const msg = el("p", { className: "m01-sllogic__msg" });
if (!node) {
host.replaceChildren();
return;
}
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
const results = el("ul", { className: "m01-sllogic__list" });
const fieldsHost = el("div", { className: "m01-sllogic__fields" });
const box = el("input", {
className: "m01-sllogic__input",
attrs: { type: "text", placeholder: tt("Search"), "aria-label": tt("Search") },
});
const emit = (make: () => SheetLogic): void => {
msg.textContent = "";
try {
on.change(make());
} catch (e) {
msg.textContent = slPlain(e);
}
};
const choose = async (item: EasyListItem): Promise<void> => {
msg.textContent = tt("Loading");
try {
const opened = await openEasy(item.key);
rows.set(item.key, opened.row);
msg.textContent = "";
on.change(applyLogic(sl, node.id, opened.row, opened.trial));
} catch (e) {
msg.textContent = slPlain(e);
}
};
const search = async (): Promise<void> => {
msg.textContent = tt("Loading");
try {
const found = await listEasy(box.value.trim());
msg.textContent = "";
results.replaceChildren(
...(found.length
? found.slice(0, LIMIT).map((it) => {
const b = createButton({
label: `${it.name} · ${it.resultUnit}${it.where ? ` · ${it.where}` : ""}`,
variant: "ghost",
size: "sm",
onClick: () => void choose(it),
});
return el("li", { children: [b] });
})
: [el("li", { className: "m01-sllogic__muted", text: tt("NoHit") })]),
...(found.length > LIMIT
? [el("li", { className: "m01-sllogic__muted", text: tt("More") })]
: []),
);
} catch (e) {
msg.textContent = slPlain(e);
}
};
box.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") void search();
});
/** 넘길 값 칸 — 고르기가 있으면 고르기 · 아니면 숫자 · 다른 상자에서 이어진 칸은 읽기만 */
const fieldFor = (pill: SlPill, spec: LogicInput | undefined): HTMLElement => {
const cur = sheet?.칸[pill.cell];
const linked = cur?.식 !== undefined;
const put = (v: number | string): void =>
emit(() => ({
...sl,
workbook: {
...sl.workbook,
시트: sl.workbook.시트.map((s) =>
s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [pill.cell]: { 값: v } } } : s,
),
},
}));
const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name });
let control: HTMLElement;
if (spec?.고르기?.length && !linked) {
control = createSelectField({
options: [
{ value: "", text: tt("Pick") },
...spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
],
value: cur?.값 === undefined ? "" : String(cur.값),
compact: true,
ariaLabel: pill.name,
onChange: (v) => {
const hit = spec.고르기?.find((o) => String(o) === v);
if (hit !== undefined) put(hit);
},
}).root;
} else {
const input = el("input", {
className: "m01-sllogic__input",
attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name },
});
if (spec?.범위) input.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
input.value = linked || cur?.값 === undefined ? "" : String(cur.값);
input.readOnly = linked;
input.addEventListener("change", () => {
const raw = input.value.trim();
const n = Number(raw);
if (raw === "") return;
if (Number.isNaN(n)) msg.textContent = tt("Bad");
else put(n);
});
control = input;
}
return el("label", {
className: "m01-sllogic__field",
children: [
label,
control,
...(linked ? [el("small", { className: "m01-sllogic__muted", text: tt("Linked") })] : []),
],
});
};
const drawFields = (row: LogicRow | undefined): void => {
const passed = node.pillsIn.filter((p) => !p.outside);
fieldsHost.replaceChildren(
el("strong", { text: tt("Inputs") }),
...(passed.length
? passed.map((p) =>
fieldFor(
p,
row?.입력?.find((i) => i.이름 === p.name),
),
)
: [el("p", { className: "m01-sllogic__muted", text: tt("NoInputs") })]),
el("p", { className: "m01-sllogic__muted", text: tt("Hint") }),
);
};
host.replaceChildren(
el("strong", { className: "m01-sllogic__title", text: `${tt("Title")} · ${node.name}` }),
el("p", {
className: "m01-sllogic__chosen",
text: node.ref ? `${tt("Chosen")}: ${node.name}` : tt("None"),
}),
el("div", {
className: "m01-sllogic__search",
children: [
box,
createButton({ label: tt("Find"), size: "sm", onClick: () => void search() }),
],
}),
results,
...(node.ref ? [fieldsHost] : []),
msg,
);
if (node.ref) {
const known = rows.get(node.ref);
drawFields(known);
if (!known) {
void openEasy(node.ref).then(
(o) => {
rows.set(o.row.키, o.row);
drawFields(o.row);
},
() => undefined,
);
}
}
}
@@ -54,6 +54,8 @@
.m01-slpanel-sheet {
position: relative;
min-height: 260px;
/* 그 시트가 쓰는 줄 수만큼(Entry 가 --sl-sheet-h 를 정함) — 안쪽 세로 밀기 없음(사용자 요청) */
height: var(--sl-sheet-h, 260px);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
overflow: hidden;
+28 -5
View File
@@ -24,6 +24,7 @@ import "./M01_SL_Result.css";
const TEXT = {
Per: ["{per} 당", "per {per}"],
Total: ["합계", "Total"],
Value: ["결과값", "Result value"],
Won: ["원", "KRW"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
@@ -47,6 +48,7 @@ const tr = (key: TextKey, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m);
const COSTS = ["노무비", "재료비", "경비"] as const;
const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
@@ -157,13 +159,34 @@ export function renderResult(
values: SlEvalResult,
on: { change(sl: SheetLogic): void },
): void {
const money = !sl.resultUnit || sl.resultUnit.startsWith("원");
const { 계 } = values.result;
const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : "";
/** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */
const plainValue = (): number | null => {
const box = sl.nodes.find((n) => n.kind === "result");
const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : [];
const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find(
(v) => typeof v === "number" && Number.isFinite(v),
);
return typeof first === "number" ? first : null;
};
const perUnit = sl.resultUnit.split("/").slice(1).join("/");
const perText = !perUnit
? tr("Total")
: tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
const shown = money ? 계 : plainValue();
const big = el("p", {
className: "m01-slresult__big",
children: [
el("span", { text: per ? tr("Per", { per: `1${per}` }) : tr("Total") }),
el("strong", { text: 계 === null ? "—" : `${formatMoney(계)} ${tr("Won")}` }),
el("span", { text: money ? perText : tr("Value") }),
el("strong", {
text:
shown === null
? "—"
: money
? `${formatMoney(shown)} ${tr("Won")}`
: `${num(shown)} ${sl.resultUnit}`.trim(),
}),
],
});
const costs = el("dl", {
@@ -191,7 +214,7 @@ export function renderResult(
middle: {},
};
const sheetHost = el("div", { className: "m01-slresult__sheet" });
renderSheet(sheetHost, head, ans);
if (money) renderSheet(sheetHost, head, ans);
const msg = el("p", { className: "m01-slresult__msg" });
const put =
@@ -225,7 +248,7 @@ export function renderResult(
: [];
host.replaceChildren(
big,
costs,
...(money ? [costs] : []),
...strip,
...(problems.length
? [
+42 -4
View File
@@ -97,7 +97,7 @@ def delete(key: str) -> dict:
# ── 바깥 값 ────────────────────────────────────────────────────────────
# 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}`
# 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`)
# 노임 · 자재 {kind, key} → 그 줄 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값)
# 노임 · 자재 {kind, key, unit?} → 그 줄 단가(`/calc` 와 같은 셈) · 자재 key = 키 · 검색어 묶음
# 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1)
# 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움)
RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음
@@ -131,8 +131,46 @@ def _money(got: dict, field: str | None) -> dict:
return {"ok": True, "value": got["sums"][pick]}
def _price(kind: str, key: str) -> dict:
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
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 {}
out = {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None}
return {**out, **({"reason": one["단위경고"]} if one.get("단위경고") else {})}
def _price(kind: str, key: Any, unit: str = "") -> dict:
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로 · 자재는 키 또는 검색어 묶음."""
cond = _pick(key) if kind == "자재" else None
if cond is not None:
return _material(cond, unit)
key = str(key or "")
try:
brief = store.loaded()[1].get(key)
except cm.mf.FormulaError as e:
@@ -205,7 +243,7 @@ def one_outside(item: dict) -> dict:
if kind == "표":
got = _table(str(item.get("table") or ""))
elif kind in ("노임", "자재"):
got = _price(kind, str(item.get("key") or ""))
got = _price(kind, item.get("key"), str(item.get("unit") or ""))
elif kind == "장비":
inputs = item.get("inputs") or {}
line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"}
+1
View File
@@ -0,0 +1 @@
_data/
+136
View File
@@ -0,0 +1,136 @@
/* =============================================================================
* M02_CF_Api.ts
* 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 넣고
* 같은 엔진으로 함수 상자 시트를 다시 셈) · 지우기 · 입력 값 한 번에 받기 · 사용자 요소
* 서버 = `M02_CF_Router.py`(`/api/m02/cf/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`
* ========================================================================== */
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
/** 입력 그룹 — M01 저장 그룹 + 사용자 요소 */
export type CfGroup =
"인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율" | "사용자";
/** 비용 종류 칸 */
export type CfCost = "노무비" | "재료비" | "경비" | "계";
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소는 `_data`)에서 새로
* · 인력 · 재료 = 그 줄 단가(기존 계산과 같은 셈 · 준용 · 낮은 값) — 재료 key 는 키 또는 검색어 묶음 JSON 글,
* unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가
* · 유가전력 · 환율 · 요율 · 사용자 = 그 줄 값
* · 기계 = key(EQ…) + field(값 칸 이름 — 가격 · 손료계수 …) 또는 element(기계 시간당 경비 로직 부름 글) + inputs → 한 시간 값
* · 소요량 · 계수 = 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「이름·아래」 「이름·위」) · row 를 주면 그 줄 하나 */
export type CfQuery =
| { id: string; group: "인력" | "유가전력" | "환율" | "요율" | "사용자"; key: string }
| { id: string; group: "재료"; key: string; unit?: string }
| { id: string; group: "기계"; key: string; field?: string }
| {
id: string;
group: "기계";
element: string;
inputs: Record<string, unknown>;
field?: CfCost;
}
| { id: string; group: "소요량" | "계수"; table: string; row?: number };
/** 입력 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
export interface CfValue {
id: string;
ok: boolean;
value?: number | string | null;
rows?: (string | number | null)[][];
name?: string;
unit?: string;
reason?: string;
}
/** 저장 때 입력 값을 넣을 자리 — 함수 상자 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
export interface CfFill {
item: CfQuery;
sheet: string;
cell: string;
}
/** 다이어그램 한 벌 = 통합문서(함수 상자 = 시트) + 상자 · 선(다른 창 칸은 그대로 저장 · 돌려줌) */
export interface CfLogic {
/** 새 것 null · 저장하면 서버가 `CF-0001` 꼴로 줌 · 견본은 그 이름(저장하면 새 번호) */
key: string | null;
name: string;
resultUnit?: string;
workbook: Workbook;
/** 입력 값 자리 — 서버가 저장 때 마스터에서 다시 넣음 */
fills?: CfFill[];
[extra: string]: unknown;
}
/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */
export interface CfListItem {
key: string;
name: string;
resultUnit: string;
saved: string;
sample: boolean;
}
/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 입력 값 */
export interface CfSaved {
key: string;
logic: CfLogic;
values: CfValue[];
}
/** 사용자 요소 — 마스터에 없는 요소(시험 폴더 `_data` 에만) · 키 `UE-0001` 꼴 */
export interface CfUserElement {
key: string;
group: CfGroup;
이름: string;
규격: string;
단위: string;
값: number | null;
비고?: string;
}
const notReady = (): never => {
throw new Error("준비 중");
};
export async function cfList(): Promise<CfListItem[]> {
return notReady();
}
export async function cfGet(_key: string): Promise<CfLogic> {
return notReady();
}
export async function cfSave(_logic: CfLogic): Promise<CfSaved> {
return notReady();
}
export async function cfDelete(_key: string): Promise<void> {
return notReady();
}
export async function cfValues(_items: CfQuery[]): Promise<CfValue[]> {
return notReady();
}
/** 사용자 요소 목록 */
export async function cfUserElements(): Promise<CfUserElement[]> {
return notReady();
}
/** 사용자 요소 만들기 — 키는 서버가 줌 */
export async function cfUserElementAdd(
_element: Omit<CfUserElement, "key">,
): Promise<CfUserElement> {
return notReady();
}
export async function cfUserElementDelete(_key: string): Promise<void> {
return notReady();
}
/** 실패 → 쉬운 말 */
export function cfPlain(_e: unknown): string {
return notReady();
}
@@ -0,0 +1,44 @@
/* =============================================================================
* M02_CF_Eval.ts
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
* ========================================================================== */
import type { CfLogic } from "./M02_CF_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
export type CfValue = number | string | boolean | null | (number | string | null)[][];
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
export type CfValues = Record<string, Record<string, CfValue>>;
export interface CfEvalResult {
/** 상자 id → 내보내는 알약 이름 → 값 */
ports: Record<string, Record<string, CfValue>>;
/** 출력 상자 id → 값 */
outputs: Record<string, number | string | boolean | null>;
cost: {
노무비: number;
재료비: number;
경비: number;
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
값들: Record<string, number | string | boolean | null>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
const notReady = (): never => {
throw new Error("준비 중");
};
/** 화면 계산 */
export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult {
return notReady();
}
/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] {
return notReady();
}
+164
View File
@@ -0,0 +1,164 @@
/* =============================================================================
* M02_CF_Model.ts
* 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로
* ========================================================================== */
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 notReady = (): never => {
throw new Error("준비 중");
};
export function newLogic(_name?: string, _resultUnit?: string): CfLogic {
return notReady();
}
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나 · 출력은 노무비 */
export function addBox(
_lg: CfLogic,
_kind: CfKind,
_x: number,
_y: number,
): { lg: CfLogic; box: CfBox } {
return notReady();
}
/** 상자 지우기 — 이어진 선도 지움 */
export function removeBox(_lg: CfLogic, _id: string): CfLogic {
return notReady();
}
export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic {
return notReady();
}
export function setColor(_lg: CfLogic, _id: string, _color: string): CfLogic {
return notReady();
}
export function setUnit(_lg: CfLogic, _id: string, _unit: string): CfLogic {
return notReady();
}
/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */
export function portsOut(_box: CfBox): CfPill[] {
return notReady();
}
/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */
export function portsIn(_box: CfBox): CfPill[] {
return notReady();
}
/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */
export function link(
_lg: CfLogic,
_from: CfPort,
_to: CfPort,
): { lg: CfLogic; why: string | null } {
return notReady();
}
export function unlink(_lg: CfLogic, _id: string): CfLogic {
return notReady();
}
+45 -11
View File
@@ -29,7 +29,7 @@ HOOKS = r"""
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripTypeScriptTypes } from "node:module";
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.ts)?$/;
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.ts)?$/;
export async function resolve(spec, ctx, next) {
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
const u = new URL(spec + ".ts", ctx.parentURL);
@@ -161,12 +161,25 @@ def _tables_of(row: dict, tables: dict) -> dict:
return {k: tables[k] for k in keys if k in tables}
def _names_of(row: dict, master) -> dict:
"""다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)"""
cm, files, _ = master
m = cm.mf.Master(files)
keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False)))
return {k: str(m.get(k).get("이름") or k) for k in keys}
def _num(v) -> float:
"""빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan"""
if v is None:
return 0.0
return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan")
def _fill(sl: dict, places: list, py_lines: list, master) -> None:
"""바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`"""
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
cm, files, _ = master
m = cm.mf.Master(files)
nodes = {n["id"]: n for n in sl["nodes"]}
@@ -180,14 +193,21 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None:
elif kind == "자재":
name = nodes[item["id"].split(":")[0]]["name"]
value = next(x["단가"] for x in py_lines if x["이름"] == name)
elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목
line = {
"종류": "로직",
"요소": item["element"],
"이름": "장비",
"단위": "시간",
"수량": "1",
}
inputs = item["inputs"]
one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "",
"입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip
value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0)
else:
key = (
item["key"]
if kind == "로직"
else re.match(r"로직\((\w+)", item["element"]).group(1)
)
got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v
for k, v in item["inputs"].items()}) # fmt: skip
got = cm.mf.run(m, item["key"], {k: Decimal(str(v)) if isinstance(v, (int, float)) else v
for k, v in item["inputs"].items()}) # fmt: skip
value = got["결과"] if "결과" in got else got[item.get("field") or "계"]
if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0)
continue
@@ -197,7 +217,8 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None:
def _result_cells(sl: dict) -> dict:
node = next(n for n in sl["nodes"] if n["kind"] == "result")
return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]}
# 결과 틀 — 노무비 · 재료비 · 경비 = 입력 알약 · 계(또는 결과) = 출력 알약
return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsIn"] + node["pillsOut"]}
@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES])
@@ -206,9 +227,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
rep = _node(
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });"
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given, names: A.names });"
"out({ ...r, places: I.outsideOf(r.sl) });",
{"row": row, "tables": _tables_of(row, master[2]), "given": given},
{
"row": row,
"tables": _tables_of(row, master[2]),
"given": given,
"names": _names_of(row, master),
},
)
assert rep["problems"] == []
sl = rep["sl"]
@@ -219,6 +245,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m
for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴)
if n["kind"] == "table":
assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"]
# 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액)
assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])]
for n in sl["nodes"]:
if n["kind"] == "item" and n.get("ref", "").startswith("{"):
ref = json.loads(n["ref"])
assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율")
pills = {p["name"]: p["cell"] for p in n["pillsOut"]}
assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6")
for e in sl["edges"]:
assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"])
assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"])
+60
View File
@@ -304,3 +304,63 @@ def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None:
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == rows
def _material_cond() -> tuple[dict, str]:
"""값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음)."""
rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"]
row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분"))
cond = {
"구분": row["구분"],
"상세구분": row["상세구분"],
"검색어": f"{row['이름']} {row['규격'] or ''}".strip(),
}
return cond, row["단위"]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None:
cond, unit = _material_cond()
line = {
"종류": "재료",
"요소": cond,
"이름": "재료",
"단위": unit,
"수량": "1",
"비목": "재료비",
}
row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체",
"입력": [],
"중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip
want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"]
assert want is not None
text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글
bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False)
items = [
{"id": "s", "kind": "자재", "key": text, "unit": unit},
{"id": "d", "kind": "자재", "key": cond, "unit": unit},
{"id": "x", "kind": "자재", "key": bad, "unit": unit},
]
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"]
assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음"
# 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록)
logic = {
"key": None,
"name": "자재 견본",
"resultUnit": "원/㎡",
"workbook": {
"종류": "통합문서",
"판": 1,
"서식": [{}],
"시트": [{"id": "s1", "이름": "재료", "칸": {}}],
},
"nodes": [],
"edges": [],
"outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}],
}
saved = _post(client, "/api/m01/sl/save", {"logic": logic})
assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want
_post(client, "/api/m01/sl/delete", {"key": saved["key"]})