Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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,12 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Table_Build.ts
|
||||
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
|
||||
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
|
||||
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts`
|
||||
*
|
||||
* 시트 모양(표 상자 시트 하나):
|
||||
* A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움
|
||||
* 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로)
|
||||
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
|
||||
* 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로)
|
||||
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
|
||||
* 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface TblPlan {
|
||||
outCells: Record<string, string>;
|
||||
idxCell: string;
|
||||
statusCell: string;
|
||||
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */
|
||||
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */
|
||||
helperFrom: number;
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -74,7 +74,8 @@ const colIndex = (letters: string): number =>
|
||||
[...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1;
|
||||
const at = (r: number, c: number): string => `${colName(c)}${r}`;
|
||||
|
||||
const MARGIN = 20;
|
||||
/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */
|
||||
const marginOf = (n: number): number => Math.max(20, n);
|
||||
const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */
|
||||
@@ -148,16 +149,16 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음");
|
||||
const rule = info.rule ?? "초과·이하";
|
||||
const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule));
|
||||
const R = (range ? n : n + MARGIN) + 1;
|
||||
const R = n + marginOf(n) + 1;
|
||||
const rng = (L: string): string => `${L}$2:${L}$${R}`;
|
||||
|
||||
// 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
|
||||
// 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
|
||||
const variants: string[][] = [[]];
|
||||
if (!range) {
|
||||
const bl = blankable.slice(0, 2);
|
||||
const subsets =
|
||||
bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : [];
|
||||
variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음
|
||||
variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음
|
||||
}
|
||||
const keyCols = exact.length ? variants : [];
|
||||
const kStart = W + 1;
|
||||
@@ -166,7 +167,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
const V = L + 1;
|
||||
const cells: Record<string, Cell> = {};
|
||||
keyCols.forEach((skip, v) => {
|
||||
cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" };
|
||||
cells[at(1, kStart + v)] = {
|
||||
값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건",
|
||||
};
|
||||
const rest = exact.filter((c) => !skip.includes(c));
|
||||
for (let r = 2; r <= R; r++) {
|
||||
const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&');
|
||||
@@ -183,7 +186,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
|
||||
// 찾기 판
|
||||
let row = 1;
|
||||
cells[at(row++, L)] = { 값: "표에서 찾기" };
|
||||
cells[at(row++, L)] = {
|
||||
값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "),
|
||||
};
|
||||
const inCells: Record<string, string> = {};
|
||||
const inputOf: Record<string, string> = {};
|
||||
for (const c of conds) {
|
||||
@@ -194,6 +199,14 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
inputOf[c] = at(row, V);
|
||||
row++;
|
||||
}
|
||||
// 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감)
|
||||
const rowsCell = ((): string => {
|
||||
cells[at(row, L)] = { 값: "표 줄 수" };
|
||||
cells[at(row, V)] = {
|
||||
식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`,
|
||||
};
|
||||
return at(row++, V);
|
||||
})();
|
||||
const keyOf = (skip: string[]): string =>
|
||||
exact
|
||||
.filter((c) => !skip.includes(c))
|
||||
@@ -210,10 +223,10 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
let idx = "";
|
||||
let count = "";
|
||||
if (exact.length) {
|
||||
const keyCell = idxRow("열쇠", keyOf([]));
|
||||
const keyCell = idxRow("합친 조건", keyOf([]));
|
||||
const ks = keyCols.map((skip, v) => {
|
||||
if (v === 0) return keyCell;
|
||||
cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` };
|
||||
cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` };
|
||||
cells[at(row, V)] = { 식: keyOf(skip) };
|
||||
return at(row++, V);
|
||||
});
|
||||
@@ -224,7 +237,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
);
|
||||
count = idxRow(
|
||||
"일치 수",
|
||||
`IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
|
||||
`IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
|
||||
);
|
||||
} else {
|
||||
const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`);
|
||||
@@ -242,7 +255,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
const inside = lowBased
|
||||
? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<INDEX(${up},${idx})))`
|
||||
: `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`;
|
||||
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`);
|
||||
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`);
|
||||
} else {
|
||||
warnings.push("조건 칸이 없음 — 찾을 줄이 없음");
|
||||
count = idxRow("일치 수", "0");
|
||||
@@ -294,6 +307,21 @@ export function pillsOf(
|
||||
const sheetIndexOf = (sl: SheetLogic, node: SlNode): number =>
|
||||
sl.workbook.시트.findIndex((s) => s.id === node.sheetId);
|
||||
|
||||
/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */
|
||||
function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void {
|
||||
if (sheet.이름 === want) return;
|
||||
const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름));
|
||||
let name = want;
|
||||
for (let i = 2; taken.has(name); i++) name = `${want} (${i})`;
|
||||
const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`;
|
||||
const old = sheet.이름;
|
||||
const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])];
|
||||
for (const s of sl.workbook.시트)
|
||||
for (const cell of Object.values(s.칸))
|
||||
if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`);
|
||||
sheet.이름 = name;
|
||||
}
|
||||
|
||||
/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */
|
||||
export function applyTable(
|
||||
sl: SheetLogic,
|
||||
@@ -331,6 +359,7 @@ export function applyTable(
|
||||
!(e.to.node === nodeId && gone("in", e.to.pill)),
|
||||
);
|
||||
node.name = `표 · ${info.name}`;
|
||||
renameSheet(next, sheet, node.name);
|
||||
node.ref = opts.ref;
|
||||
node.pillsIn = pills.pillsIn;
|
||||
node.pillsOut = pills.pillsOut;
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -31,11 +31,21 @@ const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "plan") {
|
||||
const plan = m.planLookup(job.info, job.roles);
|
||||
const cells = { ...m.blockCells(job.info), ...plan.cells };
|
||||
const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells };
|
||||
for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v };
|
||||
return { cells, plan };
|
||||
}
|
||||
if (job.op === "paste") return m.parsePaste(job.text);
|
||||
if (job.op === "rename") {
|
||||
const sl = { key: null, name: "t", resultUnit: "", edges: [],
|
||||
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [
|
||||
{ id: "s1", 이름: "표", 칸: {} },
|
||||
{ id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] },
|
||||
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
|
||||
const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl;
|
||||
const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? ""));
|
||||
return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts };
|
||||
}
|
||||
if (job.op === "reapply") {
|
||||
const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [],
|
||||
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] },
|
||||
@@ -230,3 +240,37 @@ def test_블록_머리_차례():
|
||||
"폭·아래",
|
||||
"폭·위",
|
||||
]
|
||||
|
||||
|
||||
def test_표가_늘어도_찾기_범위가_따라감():
|
||||
"""마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다)."""
|
||||
small = {**RATE, "rows": RATE["rows"][:2]}
|
||||
extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)]
|
||||
grown = {**small, "rows": [*small["rows"], *extra]}
|
||||
roles = {"cond": ["높이"], "out": ["증가율"]}
|
||||
plan = run(
|
||||
[{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}]
|
||||
)[0]
|
||||
book = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "t",
|
||||
"서식": [{}],
|
||||
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
|
||||
"활성": "s1",
|
||||
}
|
||||
values = recalc_workbooks([book])[0]["s1"]
|
||||
assert values[plan["plan"]["statusCell"]]["글"] == "찾음"
|
||||
assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72
|
||||
|
||||
|
||||
def test_표_상자에_표_번호가_안_보임_이름으로_보임():
|
||||
info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"}
|
||||
roles = {"cond": list(info["conds"]), "out": ["석공"]}
|
||||
out = run([{"op": "rename", "info": info, "roles": roles}])[0]
|
||||
assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"]
|
||||
assert out["ref"] == "QF000421" # 번호는 상자 ref 에만
|
||||
assert "QF000421" not in " ".join(out["texts"])
|
||||
assert any("13-4-1" in t and "1㎡" in t for t in out["texts"])
|
||||
assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2"
|
||||
assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1"
|
||||
|
||||
Reference in New Issue
Block a user