Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
.m01-sltbl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m01-sltbl__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-sltbl__box {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.m01-sltbl__box > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m01-sltbl__box[open] > summary {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__box > :not(summary) {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 22vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-sltbl__row,
|
||||
.m01-sltbl__role {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m01-sltbl__row:hover,
|
||||
.m01-sltbl__row:focus-visible,
|
||||
.m01-sltbl__role:hover,
|
||||
.m01-sltbl__role:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.m01-sltbl__roles > p {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
.m01-sltbl__role span {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.m01-sltbl__role.is-cond {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__role.is-out {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__role.is-off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.m01-sltbl__status {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.m01-sltbl__status.is-ok {
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
.m01-sltbl__status.is-bad {
|
||||
border-left: 4px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.m01-sltbl__scroll {
|
||||
max-height: 24vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-sltbl__view table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-sltbl__view th,
|
||||
.m01-sltbl__view td {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
.m01-sltbl__view tr.is-hit td {
|
||||
background: rgba(240, 190, 40, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
.m01-sltbl__formula {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.m01-sltbl__formula code {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.m01-sltbl__warn {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-sltbl__paste {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
}
|
||||
.m01-sltbl h5 {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Table.ts
|
||||
* 시트 로직 표 상자 손잡이 — 표 상자를 고르면 시트 판 위에 붙음
|
||||
* · 품셈 표 가져오기(절 · 이름 찾기 → 표) — 표 줄은 바깥 값(사본으로 안 들고 있음)
|
||||
* · 내 표(빈 표 · 엑셀에서 붙여넣기) — 시트에 그대로
|
||||
* · 조건 칸 · 결과 칸 정하기 → 입력 알약 · 출력 알약 + 「표에서 찾기」 수식(시트 판에서 고칠 수 있음)
|
||||
* · 걸린 줄 칠하기 · 맞는 줄 없음 / 둘 이상 한 줄
|
||||
* 짜기 = `M01_SL_Table_Build.ts`(DOM 없음) · 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 10
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { ApiError } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import type { SheetLogic, SlNode } from "./M01_SL_Model";
|
||||
import {
|
||||
applyTable,
|
||||
blockCells,
|
||||
infoFromSheet,
|
||||
layoutOf,
|
||||
parsePaste,
|
||||
planLookup,
|
||||
type TblInfo,
|
||||
type TblRoles,
|
||||
} from "./M01_SL_Table_Build";
|
||||
import "./M01_SL_Table.css";
|
||||
|
||||
const TEXT = {
|
||||
NotTable: ["표 상자를 고르면 표 손잡이가 뜸", "Pick a table box to see its tools"],
|
||||
Import: ["품셈 표 가져오기", "Import a standard table"],
|
||||
Mine: ["내 표", "My table"],
|
||||
Book: ["적용 품셈", "Standard"],
|
||||
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoSection: ["찾는 절 없음", "No section"],
|
||||
SecLine: ["표 {t} 개", "{t} tables"],
|
||||
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
|
||||
TableLine: [
|
||||
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
|
||||
"{b} · {c} conditions · {v} results · {n} rows",
|
||||
],
|
||||
ImportNote: [
|
||||
"표 줄은 열 때 · 저장 때 품셈에서 새로 넣음 — 사본으로 안 들고 있음",
|
||||
"Rows are filled from the standard when opened or saved",
|
||||
],
|
||||
Empty: ["빈 표", "Empty table"],
|
||||
Paste: ["엑셀에서 붙여넣기", "Paste from Excel"],
|
||||
Heads: ["머리 칸 이름(쉼표로 나눔) — 예: 경사, 할증", "Column names, comma separated"],
|
||||
RowCount: ["줄 수", "Rows"],
|
||||
MakeEmpty: ["빈 표 만들기", "Create"],
|
||||
PasteHint: [
|
||||
"엑셀에서 머리 줄부터 복사해 여기에 붙임(탭으로 나뉜 글)",
|
||||
"Paste with the header row (tab separated)",
|
||||
],
|
||||
PutIn: ["시트에 넣기", "Put in sheet"],
|
||||
TableName: ["표 이름", "Table name"],
|
||||
Need2: ["머리 칸이 둘은 있어야 함", "Need at least two columns"],
|
||||
NeedRows: ["줄 수를 1 이상으로", "Rows must be 1 or more"],
|
||||
Source: ["원문번호 {n} · 기준 {b} · 출처 {s}", "No. {n} · base {b} · source {s}"],
|
||||
Notes: ["주(註)", "Notes"],
|
||||
Roles: ["칸 역할 — 누르면 바뀜", "Column roles — press to change"],
|
||||
RoleCond: ["조건 → 입력 알약", "Condition → input pill"],
|
||||
RoleOut: ["결과 → 출력 알약", "Result → output pill"],
|
||||
RoleOff: ["쓰지 않음", "Unused"],
|
||||
Redo: ["역할을 바꾸면 찾기 수식을 다시 씀", "Changing roles rewrites the lookup formulas"],
|
||||
Found: ["{n} 번째 줄이 걸림", "Row {n} matched"],
|
||||
NoRow: ["맞는 줄 없음 — 입력 칸 값에 맞는 줄이 표에 없음", "No matching row"],
|
||||
TwoRows: ["맞는 줄 둘 이상 — 조건 칸을 더 늘리거나 표 줄을 고침", "More than one matching row"],
|
||||
Err: ["찾기 칸에 오류가 있음", "Error in the lookup cells"],
|
||||
NoValues: ["표 줄을 아직 못 받음 — 열 때 품셈에서 넣음", "Rows are not loaded yet"],
|
||||
View: ["걸린 줄 보기", "Matched row"],
|
||||
Formulas: ["찾기 수식 — 시트 판에서 고침", "Lookup formulas — edit in the sheet"],
|
||||
Warn: ["알림", "Note"],
|
||||
Swap: ["다른 표로 바꾸기", "Change table"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
/* ── 서버 읽기 — 절 · 표 ── */
|
||||
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
}
|
||||
interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
interface RawTable {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
출처: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
범위규칙?: string | null;
|
||||
줄: TblInfo["rows"];
|
||||
주?: string[];
|
||||
}
|
||||
|
||||
const BOOKS = ["산림품셈", "건설품셈"];
|
||||
const cache = new Map<string, TblInfo>();
|
||||
const fileOf = new Map<string, string>();
|
||||
|
||||
const toInfo = (r: RawTable): TblInfo => ({
|
||||
key: r.키,
|
||||
name: r.이름,
|
||||
base: r.기준 ?? "",
|
||||
source: r.출처 ?? "",
|
||||
no: r.원문번호 ?? "",
|
||||
conds: r.조건,
|
||||
vals: r.값칸,
|
||||
rule: r.범위규칙 ?? null,
|
||||
rows: r.줄,
|
||||
notes: r.주 ?? [],
|
||||
});
|
||||
|
||||
/** 표 키 → 표 한 벌 — 파일은 목록에서 찾음(첫 가져오기 뒤에는 기억) */
|
||||
async function loadInfo(key: string): Promise<TblInfo> {
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
let file = fileOf.get(key);
|
||||
if (!file) {
|
||||
const group = /^C[FC]/.test(key) ? "계수" : "소요량";
|
||||
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
|
||||
(x) => x.키 === key,
|
||||
);
|
||||
if (!found) throw new Error(t("Failed"));
|
||||
file = found.file;
|
||||
}
|
||||
const info = toInfo((await get<{ table: RawTable }>("/table", { file, key })).table);
|
||||
cache.set(key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/* ── 역할 · 알약 ── */
|
||||
const rolesOf = (node: SlNode): TblRoles => ({
|
||||
cond: node.pillsIn.map((p) => p.name),
|
||||
out: node.pillsOut.filter((p) => !p.outside).map((p) => p.name),
|
||||
});
|
||||
|
||||
/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */
|
||||
function mineInfo(sl: SheetLogic, node: SlNode): TblInfo {
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const name = node.name.replace(/^표 · /, "");
|
||||
const conds = Object.fromEntries(node.pillsIn.map((p) => [p.name, "고르기"]));
|
||||
return sheet
|
||||
? infoFromSheet(sheet, { conds, name })
|
||||
: {
|
||||
key: "",
|
||||
name,
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds,
|
||||
vals: {},
|
||||
rule: null,
|
||||
rows: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** userTables 한 줄을 시트 모양으로 맞춤 */
|
||||
function syncUser(sl: SheetLogic, id: string, info: TblInfo): SheetLogic {
|
||||
const cols = layoutOf(info);
|
||||
const rows = info.rows.map((r) =>
|
||||
cols.map(
|
||||
(c) =>
|
||||
(Array.isArray(r[c.of])
|
||||
? ((r[c.of] as [number | null, number | null])[c.part === "아래" ? 0 : 1] ?? null)
|
||||
: (r[c.of] ?? null)) as number | string | null,
|
||||
),
|
||||
);
|
||||
const entry = { id, name: info.name, cols: cols.map((c) => c.header), rows };
|
||||
const list = (sl.userTables ?? []).filter((u) => u.id !== id);
|
||||
return { ...sl, userTables: [...list, entry] };
|
||||
}
|
||||
|
||||
/** 찾기 판을 임시 통합문서에서 풀어 → 상태 · 걸린 줄(0 부터) · 오류 */
|
||||
async function solve(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
info: TblInfo,
|
||||
withBlock: boolean,
|
||||
): Promise<{ status: string; idx: number | null }> {
|
||||
const plan = planLookup(info, rolesOf(node));
|
||||
const book = structuredClone(sl.workbook);
|
||||
const sheet = book.시트.find((s) => s.id === node.sheetId);
|
||||
if (!sheet) return { status: "", idx: null };
|
||||
if (withBlock) Object.assign(sheet.칸, blockCells(info));
|
||||
const { createCalcEngine } = await import("../../A00_Common/spreadsheet/spreadsheet_graph");
|
||||
const vals = createCalcEngine(book).snapshot()[node.sheetId] ?? {};
|
||||
const read = (a: string): string | null => {
|
||||
const v = vals[a] as Record<string, unknown> | undefined;
|
||||
if (!v) return null;
|
||||
return String(v["수"] ?? v["글"] ?? v["오류"] ?? "");
|
||||
};
|
||||
const status = read(plan.statusCell) ?? "";
|
||||
const idx = Number(read(plan.idxCell));
|
||||
return { status, idx: Number.isFinite(idx) && idx >= 1 ? idx - 1 : null };
|
||||
}
|
||||
|
||||
/* ── 화면 ── */
|
||||
export function renderTableTools(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
host.className = "m01-sltbl";
|
||||
const node = sl.nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.kind !== "table") {
|
||||
host.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("NotTable") }));
|
||||
return;
|
||||
}
|
||||
const mine = node.ref ? sl.userTables?.find((u) => u.id === node.ref) : undefined;
|
||||
const state = !node.ref ? "empty" : mine ? "mine" : "base";
|
||||
const parts: HTMLElement[] = [];
|
||||
if (state !== "empty") parts.push(infoBox(sl, node, state, on));
|
||||
parts.push(
|
||||
importBox(sl, nodeId, state === "empty", on),
|
||||
mineBox(sl, nodeId, state === "empty", on),
|
||||
);
|
||||
host.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
const sub = (title: string, open: boolean, ...kids: HTMLElement[]): HTMLDetailsElement => {
|
||||
const d = el("details", {
|
||||
className: "m01-sltbl__box",
|
||||
children: [el("summary", { text: title }), ...kids],
|
||||
});
|
||||
d.open = open;
|
||||
return d;
|
||||
};
|
||||
|
||||
/** 품셈 표 가져오기 */
|
||||
function importBox(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
open: boolean,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): HTMLElement {
|
||||
let book = BOOKS[0];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: BOOKS.map((b) => ({ value: b, text: b })),
|
||||
value: book,
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), load()),
|
||||
});
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m01-sltbl__list" });
|
||||
const tables = el("div", { className: "m01-sltbl__list" });
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mineSeq = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("Loading") }));
|
||||
try {
|
||||
const r = await get<{ sections: SectionItem[] }>("/sections", {
|
||||
book,
|
||||
q: q.input.value.trim(),
|
||||
limit: "60",
|
||||
});
|
||||
if (mineSeq !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(r.sections.length
|
||||
? r.sections.map((s) => {
|
||||
const b = el("button", {
|
||||
className: "m01-sltbl__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: `${s.section} ${s.title}` }),
|
||||
el("span", {
|
||||
className: "m01-sltbl__muted",
|
||||
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pickSection(s));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m01-sltbl__muted", text: t("NoSection") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mineSeq === seq) showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
const pickSection = async (s: SectionItem): Promise<void> => {
|
||||
try {
|
||||
const heads: TableHead[] = [];
|
||||
for (const group of ["소요량", "계수"])
|
||||
heads.push(
|
||||
...(
|
||||
await get<{ tables: TableHead[] }>("/tables", {
|
||||
group,
|
||||
section: s.section,
|
||||
sub: book,
|
||||
size: "100",
|
||||
})
|
||||
).tables,
|
||||
);
|
||||
tables.replaceChildren(
|
||||
el("h5", { text: t("Tables") }),
|
||||
...heads.map((h) => {
|
||||
const b = el("button", {
|
||||
className: "m01-sltbl__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: h.이름 }),
|
||||
el("span", {
|
||||
className: "m01-sltbl__muted",
|
||||
text: t("TableLine", {
|
||||
b: h.기준 || "—",
|
||||
c: Object.keys(h.조건).length,
|
||||
v: Object.keys(h.값칸).length,
|
||||
n: h.count,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pickTable(h));
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
const pickTable = async (h: TableHead): Promise<void> => {
|
||||
try {
|
||||
fileOf.set(h.키, h.file);
|
||||
const info = await loadInfo(h.키);
|
||||
const roles: TblRoles = { cond: Object.keys(info.conds), out: Object.keys(info.vals) };
|
||||
on.change(
|
||||
applyTable(sl, nodeId, info, roles, { ref: h.키, fillBlock: false, outside: true }).sl,
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(plainError(e), "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
|
||||
);
|
||||
const box = sub(
|
||||
t("Import"),
|
||||
open,
|
||||
el("p", { className: "m01-sltbl__muted", text: t("ImportNote") }),
|
||||
bookBox.root,
|
||||
q.root,
|
||||
list,
|
||||
tables,
|
||||
);
|
||||
box.addEventListener("toggle", () => box.open && !list.childElementCount && void load());
|
||||
if (open) void load();
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 내 표 — 빈 표 · 붙여넣기 */
|
||||
function mineBox(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
open: boolean,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): HTMLElement {
|
||||
const name = createInputField({
|
||||
type: "text",
|
||||
label: t("TableName"),
|
||||
value: "내 표",
|
||||
compact: true,
|
||||
});
|
||||
const heads = createInputField({ type: "text", placeholder: t("Heads"), compact: true });
|
||||
const rows = createInputField({ type: "text", label: t("RowCount"), value: "2", compact: true });
|
||||
const paste = el("textarea", {
|
||||
className: "m01-sltbl__paste",
|
||||
attrs: { rows: "5", placeholder: t("PasteHint") },
|
||||
});
|
||||
const put = (info: TblInfo, roles: TblRoles): void => {
|
||||
const id = `UT${(sl.userTables?.length ?? 0) + 1}`;
|
||||
const done = applyTable(
|
||||
sl,
|
||||
nodeId,
|
||||
{ ...info, name: name.input.value.trim() || "내 표" },
|
||||
roles,
|
||||
{ ref: id, fillBlock: true, outside: false },
|
||||
);
|
||||
on.change(syncUser(done.sl, id, { ...info, name: name.input.value.trim() || "내 표" }));
|
||||
};
|
||||
const makeEmpty = createButton({
|
||||
label: t("MakeEmpty"),
|
||||
onClick: () => {
|
||||
const cols = heads.input.value
|
||||
.split(/[,,\t]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const n = Math.floor(Number(rows.input.value));
|
||||
if (cols.length < 2) return heads.setError(t("Need2"));
|
||||
if (!(n >= 1)) return rows.setError(t("NeedRows"));
|
||||
const info: TblInfo = {
|
||||
key: "",
|
||||
name: "",
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds: { [cols[0]]: "고르기" },
|
||||
vals: Object.fromEntries(cols.slice(1).map((c) => [c, ""])),
|
||||
rule: null,
|
||||
rows: Array.from({ length: n }, () => ({})),
|
||||
notes: [],
|
||||
};
|
||||
put(info, { cond: [cols[0]], out: cols.slice(1) });
|
||||
},
|
||||
});
|
||||
const pasteBtn = createButton({
|
||||
label: t("PutIn"),
|
||||
onClick: () => {
|
||||
const r = parsePaste(paste.value);
|
||||
if (r.error) return void showToast(r.error, "error");
|
||||
put(r.info, { cond: Object.keys(r.info.conds), out: Object.keys(r.info.vals) });
|
||||
},
|
||||
});
|
||||
return sub(
|
||||
t("Mine"),
|
||||
open,
|
||||
name.root,
|
||||
el("h5", { text: t("Empty") }),
|
||||
heads.root,
|
||||
rows.root,
|
||||
makeEmpty,
|
||||
el("h5", { text: t("Paste") }),
|
||||
paste,
|
||||
pasteBtn,
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 표 — 머리 · 칸 역할 · 걸린 줄 · 수식 */
|
||||
function infoBox(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
state: "base" | "mine",
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "m01-sltbl__info" });
|
||||
const meta = el("p", { className: "m01-sltbl__muted" });
|
||||
const notes = el("details", { className: "m01-sltbl__notes" });
|
||||
const view = el("div", { className: "m01-sltbl__view" });
|
||||
const status = el("p", { className: "m01-sltbl__status", attrs: { "aria-live": "polite" } });
|
||||
const roleBox = el("div", { className: "m01-sltbl__roles" });
|
||||
const formulas = el("div", { className: "m01-sltbl__formulas" });
|
||||
const roles = rolesOf(node);
|
||||
|
||||
const paint = (info: TblInfo): void => {
|
||||
const withBlock = state === "base";
|
||||
meta.textContent =
|
||||
info.no || info.source
|
||||
? t("Source", { n: info.no || "—", b: info.base || "—", s: info.source || "—" })
|
||||
: "";
|
||||
notes.replaceChildren(
|
||||
...(info.notes.length
|
||||
? [el("summary", { text: t("Notes") }), ...info.notes.map((n) => el("p", { text: n }))]
|
||||
: []),
|
||||
);
|
||||
notes.hidden = !info.notes.length;
|
||||
// 칸 역할
|
||||
const all = [
|
||||
...Object.keys(info.conds).map((c) => ({ c, isCond: true })),
|
||||
...Object.keys(info.vals).map((c) => ({ c, isCond: false })),
|
||||
];
|
||||
const cur = (c: string): "cond" | "out" | "off" =>
|
||||
roles.cond.includes(c) ? "cond" : roles.out.includes(c) ? "out" : "off";
|
||||
roleBox.replaceChildren(
|
||||
el("p", { className: "m01-sltbl__muted", text: `${t("Roles")} · ${t("Redo")}` }),
|
||||
...all.map(({ c, isCond }) => {
|
||||
const r = cur(c);
|
||||
const b = el("button", {
|
||||
className: `m01-sltbl__role is-${r}`,
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: c }),
|
||||
el("span", {
|
||||
text: r === "cond" ? t("RoleCond") : r === "out" ? t("RoleOut") : t("RoleOff"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
const next: TblRoles = {
|
||||
cond: roles.cond.filter((x) => x !== c),
|
||||
out: roles.out.filter((x) => x !== c),
|
||||
};
|
||||
const to = r === "off" ? (isCond ? "cond" : "out") : "off";
|
||||
if (to === "cond") next.cond.push(c);
|
||||
if (to === "out") next.out.push(c);
|
||||
const order = Object.keys(info.conds);
|
||||
next.cond.sort((a, z) => order.indexOf(a) - order.indexOf(z));
|
||||
const done = applyTable(sl, node.id, info, next, {
|
||||
ref: node.ref ?? "",
|
||||
fillBlock: false,
|
||||
outside: state === "base",
|
||||
});
|
||||
on.change(state === "mine" ? syncUser(done.sl, node.ref ?? "", info) : done.sl);
|
||||
});
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
// 수식 보기
|
||||
const plan = planLookup(info, roles);
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const line = (label: string, a1: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-sltbl__formula",
|
||||
children: [
|
||||
el("span", { className: "m01-sltbl__muted", text: label }),
|
||||
el("code", {
|
||||
text: sheet?.칸[a1]?.식 ? `=${sheet.칸[a1].식}` : String(sheet?.칸[a1]?.값 ?? ""),
|
||||
}),
|
||||
],
|
||||
});
|
||||
formulas.replaceChildren(
|
||||
el("h5", { text: t("Formulas") }),
|
||||
...Object.entries(plan.outCells).map(([c, a1]) => line(`${c} (${a1})`, a1)),
|
||||
...(plan.warnings.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m01-sltbl__warn",
|
||||
text: `${t("Warn")}: ${plan.warnings.join(" · ")}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
// 걸린 줄 칠하기
|
||||
const cols = layoutOf(info);
|
||||
const table = el("table");
|
||||
table.append(el("tr", { children: cols.map((c) => el("th", { text: c.header })) }));
|
||||
info.rows.forEach((r, i) =>
|
||||
table.append(
|
||||
el("tr", {
|
||||
attrs: { "data-row": String(i) },
|
||||
children: cols.map((c) => {
|
||||
const v = r[c.of];
|
||||
const x = Array.isArray(v) ? (c.part === "아래" ? v[0] : v[1]) : v;
|
||||
return el("td", { text: x === null || x === undefined ? "" : String(x) });
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
view.replaceChildren(
|
||||
el("h5", { text: t("View") }),
|
||||
el("div", { className: "m01-sltbl__scroll", children: [table] }),
|
||||
);
|
||||
void solve(sl, node, info, withBlock)
|
||||
.then(({ status: s, idx }) => {
|
||||
status.className = `m01-sltbl__status is-${s === "찾음" ? "ok" : "bad"}`;
|
||||
status.textContent =
|
||||
s === "찾음" && idx !== null
|
||||
? t("Found", { n: idx + 1 })
|
||||
: s === "맞는 줄 없음"
|
||||
? t("NoRow")
|
||||
: s === "맞는 줄 둘 이상"
|
||||
? t("TwoRows")
|
||||
: s
|
||||
? t("Err")
|
||||
: "";
|
||||
table
|
||||
.querySelectorAll("tr[data-row]")
|
||||
.forEach((tr) =>
|
||||
tr.classList.toggle(
|
||||
"is-hit",
|
||||
idx !== null && s === "찾음" && tr.getAttribute("data-row") === String(idx),
|
||||
),
|
||||
);
|
||||
table.querySelector(".is-hit")?.scrollIntoView({ block: "nearest" });
|
||||
})
|
||||
.catch(() => (status.textContent = t("Err")));
|
||||
};
|
||||
|
||||
box.append(meta, notes, roleBox, status, view, formulas);
|
||||
if (state === "base") {
|
||||
const key = node.ref ?? "";
|
||||
void loadInfo(key).then(paint, (e) => showToast(plainError(e), "error"));
|
||||
} else paint(mineInfo(sl, node));
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Table_Build.ts
|
||||
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
|
||||
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts`
|
||||
*
|
||||
* 시트 모양(표 상자 시트 하나):
|
||||
* A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움
|
||||
* 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로)
|
||||
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
|
||||
* 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
|
||||
|
||||
type Rec = Record<string, string | number | [number | null, number | null] | null | undefined>;
|
||||
|
||||
/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */
|
||||
export interface TblInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
/** 기준 「1㎡」 */
|
||||
base: string;
|
||||
source: string;
|
||||
/** 원문번호 「13-4-1」 */
|
||||
no: string;
|
||||
/** 조건 칸 → 「수」 「고르기」 「범위」 */
|
||||
conds: Record<string, string>;
|
||||
/** 값 칸 → 단위 */
|
||||
vals: Record<string, string>;
|
||||
rule: string | null;
|
||||
rows: Rec[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */
|
||||
export interface TblRoles {
|
||||
cond: string[];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
/** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */
|
||||
export interface TblCol {
|
||||
header: string;
|
||||
of: string;
|
||||
part: "값" | "아래" | "위";
|
||||
}
|
||||
|
||||
export interface TblPlan {
|
||||
cols: TblCol[];
|
||||
/** 블록 안 줄 수(머리 줄 빼고) */
|
||||
rowCount: number;
|
||||
/** 블록 밖에 쓸 칸 — A1 → 칸 */
|
||||
cells: Record<string, Cell>;
|
||||
/** 조건 칸 이름 → 입력 칸 */
|
||||
inCells: Record<string, string>;
|
||||
/** 결과 칸 이름 → 출력 칸 */
|
||||
outCells: Record<string, string>;
|
||||
idxCell: string;
|
||||
statusCell: string;
|
||||
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */
|
||||
helperFrom: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 열 글자 0 → A */
|
||||
export const colName = (c: number): string => {
|
||||
let s = "";
|
||||
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
|
||||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
return s;
|
||||
};
|
||||
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;
|
||||
const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 블록 열 차례 — 조건 칸 · 값 칸 차례 그대로 · 범위 칸은 둘로 */
|
||||
export function layoutOf(info: TblInfo): TblCol[] {
|
||||
const out: TblCol[] = [];
|
||||
for (const c of Object.keys(info.conds))
|
||||
if (info.conds[c] === "범위")
|
||||
out.push(
|
||||
{ header: `${c}·아래`, of: c, part: "아래" },
|
||||
{ header: `${c}·위`, of: c, part: "위" },
|
||||
);
|
||||
else out.push({ header: c, of: c, part: "값" });
|
||||
for (const c of Object.keys(info.vals)) out.push({ header: c, of: c, part: "값" });
|
||||
return out;
|
||||
}
|
||||
|
||||
const cellOf = (v: Rec[string]): Cell | null =>
|
||||
v === null || v === undefined || v === ""
|
||||
? null
|
||||
: typeof v === "number"
|
||||
? { 값: v }
|
||||
: { 값: String(v) };
|
||||
|
||||
/** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */
|
||||
export function blockCells(info: TblInfo): Record<string, Cell> {
|
||||
const cells: Record<string, Cell> = {};
|
||||
const cols = layoutOf(info);
|
||||
cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header }));
|
||||
info.rows.forEach((row, i) =>
|
||||
cols.forEach((col, c) => {
|
||||
const v = row[col.of];
|
||||
const x = Array.isArray(v)
|
||||
? col.part === "아래"
|
||||
? v[0]
|
||||
: v[1]
|
||||
: col.part === "값"
|
||||
? v
|
||||
: null;
|
||||
const cell = cellOf(x);
|
||||
if (cell) cells[at(i + 2, c)] = cell;
|
||||
}),
|
||||
);
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */
|
||||
const sampleOf = (info: TblInfo, c: string): string | number => {
|
||||
const v = info.rows[0]?.[c];
|
||||
if (Array.isArray(v))
|
||||
return v[0] !== null && v[1] !== null
|
||||
? Number(((v[0] + v[1]) / 2).toFixed(6))
|
||||
: (v[1] ?? v[0] ?? 0);
|
||||
return v === null || v === undefined ? "" : v;
|
||||
};
|
||||
|
||||
/** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */
|
||||
export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
const cols = layoutOf(info);
|
||||
const warnings: string[] = [];
|
||||
const W = cols.length;
|
||||
const n = info.rows.length;
|
||||
const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header));
|
||||
const conds = roles.cond.filter((c) => c in info.conds);
|
||||
const rangeCols = conds.filter((c) => info.conds[c] === "범위");
|
||||
const exact = conds.filter((c) => info.conds[c] !== "범위");
|
||||
const range = rangeCols[0];
|
||||
if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌");
|
||||
const blankable = exact.filter((c) =>
|
||||
info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""),
|
||||
);
|
||||
if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원");
|
||||
if (range && blankable.length)
|
||||
warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음");
|
||||
const rule = info.rule ?? "초과·이하";
|
||||
const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule));
|
||||
const R = (range ? n : n + MARGIN) + 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)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음
|
||||
}
|
||||
const keyCols = exact.length ? variants : [];
|
||||
const kStart = W + 1;
|
||||
const kLetters = keyCols.map((_, i) => colName(kStart + i));
|
||||
const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열
|
||||
const V = L + 1;
|
||||
const cells: Record<string, Cell> = {};
|
||||
keyCols.forEach((skip, v) => {
|
||||
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('&"|"&');
|
||||
if (!skip.length) cells[at(r, kStart + v)] = { 식: key };
|
||||
else {
|
||||
const guard = [
|
||||
...skip.map((c) => `${letter(c)}${r}=""`),
|
||||
...rest.map((c) => `${letter(c)}${r}<>""`),
|
||||
].join(",");
|
||||
cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 찾기 판
|
||||
let row = 1;
|
||||
cells[at(row++, L)] = { 값: "표에서 찾기" };
|
||||
const inCells: Record<string, string> = {};
|
||||
const inputOf: Record<string, string> = {};
|
||||
for (const c of conds) {
|
||||
cells[at(row, L)] = { 값: c };
|
||||
const sample = sampleOf(info, c);
|
||||
if (sample !== "") cells[at(row, V)] = { 값: sample };
|
||||
inCells[c] = at(row, V);
|
||||
inputOf[c] = at(row, V);
|
||||
row++;
|
||||
}
|
||||
const keyOf = (skip: string[]): string =>
|
||||
exact
|
||||
.filter((c) => !skip.includes(c))
|
||||
.map((c) => inputOf[c])
|
||||
.join('&"|"&');
|
||||
const idxRow = (label: string, f: string): string => {
|
||||
cells[at(row, L)] = { 값: label };
|
||||
cells[at(row, V)] = { 식: f };
|
||||
return at(row++, V);
|
||||
};
|
||||
const up = range ? rng(letter(`${range}·위`)) : "";
|
||||
const lo = range ? rng(letter(`${range}·아래`)) : "";
|
||||
const x = range ? inputOf[range] : "";
|
||||
let idx = "";
|
||||
let count = "";
|
||||
if (exact.length) {
|
||||
const keyCell = idxRow("열쇠", keyOf([]));
|
||||
const ks = keyCols.map((skip, v) => {
|
||||
if (v === 0) return keyCell;
|
||||
cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` };
|
||||
cells[at(row, V)] = { 식: keyOf(skip) };
|
||||
return at(row++, V);
|
||||
});
|
||||
if (range) {
|
||||
idx = idxRow(
|
||||
"걸린 줄",
|
||||
`MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`,
|
||||
);
|
||||
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)`,
|
||||
);
|
||||
} else {
|
||||
const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`);
|
||||
count = idxRow(
|
||||
"일치 수",
|
||||
found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"),
|
||||
);
|
||||
const chain = ks
|
||||
.map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`)
|
||||
.reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()");
|
||||
idx = idxRow("걸린 줄", chain);
|
||||
}
|
||||
} else if (range) {
|
||||
idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`);
|
||||
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)`);
|
||||
} else {
|
||||
warnings.push("조건 칸이 없음 — 찾을 줄이 없음");
|
||||
count = idxRow("일치 수", "0");
|
||||
idx = idxRow("걸린 줄", "NA()");
|
||||
}
|
||||
if (lowBased && range && exact.length)
|
||||
warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음");
|
||||
const statusCell = idxRow(
|
||||
"상태",
|
||||
`IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`,
|
||||
);
|
||||
const outCells: Record<string, string> = {};
|
||||
for (const c of roles.out.filter((o) => o in info.vals)) {
|
||||
outCells[c] = idxRow(
|
||||
c,
|
||||
`IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(c))},${idx}),NA())`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
cols,
|
||||
rowCount: n,
|
||||
cells,
|
||||
inCells,
|
||||
outCells,
|
||||
idxCell: idx,
|
||||
statusCell,
|
||||
helperFrom: W,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */
|
||||
export function pillsOf(
|
||||
info: TblInfo,
|
||||
plan: TblPlan,
|
||||
outside: boolean,
|
||||
): { pillsIn: SlPill[]; pillsOut: SlPill[] } {
|
||||
const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell }));
|
||||
const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({
|
||||
name,
|
||||
cell,
|
||||
unit: info.vals[name] || undefined,
|
||||
}));
|
||||
if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true });
|
||||
return { pillsIn, pillsOut };
|
||||
}
|
||||
|
||||
const sheetIndexOf = (sl: SheetLogic, node: SlNode): number =>
|
||||
sl.workbook.시트.findIndex((s) => s.id === node.sheetId);
|
||||
|
||||
/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */
|
||||
export function applyTable(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
info: TblInfo,
|
||||
roles: TblRoles,
|
||||
opts: { ref: string; fillBlock: boolean; outside: boolean },
|
||||
): { sl: SheetLogic; plan: TblPlan } {
|
||||
const next = structuredClone(sl);
|
||||
const node = next.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) throw new Error("없는 상자");
|
||||
const si = sheetIndexOf(next, node);
|
||||
if (si < 0) throw new Error("상자의 시트가 없음");
|
||||
const plan = planLookup(info, roles);
|
||||
const sheet: Sheet = next.workbook.시트[si];
|
||||
// 이미 있던 입력 칸 내용(예시값 · 선을 그은 참조 수식)은 같은 이름이면 그대로 가져감
|
||||
const oldIn = Object.fromEntries(node.pillsIn.map((p) => [p.name, sheet.칸[p.cell]]));
|
||||
const keep = Object.entries(sheet.칸).filter(
|
||||
([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom,
|
||||
);
|
||||
sheet.칸 = Object.fromEntries(keep);
|
||||
if (opts.fillBlock) {
|
||||
for (const a of Object.keys(sheet.칸)) delete sheet.칸[a];
|
||||
Object.assign(sheet.칸, blockCells(info));
|
||||
}
|
||||
Object.assign(sheet.칸, plan.cells);
|
||||
for (const [name, cell] of Object.entries(plan.inCells))
|
||||
if (oldIn[name]) sheet.칸[cell] = oldIn[name];
|
||||
const pills = pillsOf(info, plan, opts.outside);
|
||||
const gone = (dir: "in" | "out", name: string): boolean =>
|
||||
!(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name);
|
||||
next.edges = next.edges.filter(
|
||||
(e) =>
|
||||
!(e.from.node === nodeId && gone("out", e.from.pill)) &&
|
||||
!(e.to.node === nodeId && gone("in", e.to.pill)),
|
||||
);
|
||||
node.name = `표 · ${info.name}`;
|
||||
node.ref = opts.ref;
|
||||
node.pillsIn = pills.pillsIn;
|
||||
node.pillsOut = pills.pillsOut;
|
||||
return { sl: next, plan };
|
||||
}
|
||||
|
||||
/** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */
|
||||
export function parsePaste(text: string): { info: TblInfo; error: string | null } {
|
||||
const lines = text
|
||||
.replace(/\r/g, "")
|
||||
.split("\n")
|
||||
.filter((l) => l.trim() !== "");
|
||||
const empty: TblInfo = {
|
||||
key: "",
|
||||
name: "",
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds: {},
|
||||
vals: {},
|
||||
rule: null,
|
||||
rows: [],
|
||||
notes: [],
|
||||
};
|
||||
if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" };
|
||||
const grid = lines.map((l) => l.split("\t").map((c) => c.trim()));
|
||||
const head = grid[0];
|
||||
if (head.some((h) => h === "") || new Set(head).size !== head.length)
|
||||
return { info: empty, error: "머리 칸 이름이 비었거나 겹침" };
|
||||
const num = (s: string): number | null =>
|
||||
s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null;
|
||||
const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/;
|
||||
const body = grid.slice(1);
|
||||
const isRange = (c: number): boolean =>
|
||||
body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) &&
|
||||
body.every((r) => !r[c] || rangeRe.test(r[c]));
|
||||
const rows: Rec[] = body.map((r) => {
|
||||
const rec: Rec = {};
|
||||
head.forEach((h, c) => {
|
||||
const s = r[c] ?? "";
|
||||
if (isRange(c)) {
|
||||
const m = rangeRe.exec(s);
|
||||
rec[h] = m
|
||||
? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])]
|
||||
: null;
|
||||
} else rec[h] = s === "" ? null : (num(s) ?? s);
|
||||
});
|
||||
return rec;
|
||||
});
|
||||
// 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값
|
||||
const conds: Record<string, string> = {};
|
||||
const vals: Record<string, string> = {};
|
||||
head.forEach((h, c) => {
|
||||
const numeric = body.every((r) => !r[c] || num(r[c]) !== null);
|
||||
if (isRange(c)) conds[h] = "범위";
|
||||
else if (!numeric) conds[h] = "고르기";
|
||||
else if (c === 0 && head.length > 1) conds[h] = "수";
|
||||
else vals[h] = "";
|
||||
});
|
||||
if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" };
|
||||
return { info: { ...empty, conds, vals, rows }, error: null };
|
||||
}
|
||||
|
||||
/** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */
|
||||
export function infoFromSheet(sheet: Sheet, prev: Pick<TblInfo, "conds" | "name">): TblInfo {
|
||||
const heads: string[] = [];
|
||||
for (let c = 0; ; c++) {
|
||||
const h = sheet.칸[at(1, c)]?.값;
|
||||
if (h === undefined || h === "") break;
|
||||
heads.push(String(h));
|
||||
}
|
||||
let last = 1;
|
||||
for (const a of Object.keys(sheet.칸)) {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(a);
|
||||
if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2]));
|
||||
}
|
||||
const conds: Record<string, string> = {};
|
||||
const vals: Record<string, string> = {};
|
||||
const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = [];
|
||||
heads.forEach((h, c) => {
|
||||
const m = /^(.+)·(아래|위)$/.exec(h);
|
||||
names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c });
|
||||
});
|
||||
for (const nm of names) {
|
||||
if (nm.part !== "값") conds[nm.of] = "범위";
|
||||
else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of];
|
||||
else vals[nm.of] = "";
|
||||
}
|
||||
const rows: Rec[] = [];
|
||||
for (let r = 2; r <= last; r++) {
|
||||
const rec: Rec = {};
|
||||
for (const nm of names) {
|
||||
const v = sheet.칸[at(r, nm.c)]?.값;
|
||||
const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v;
|
||||
if (nm.part === "값") rec[nm.of] = x;
|
||||
else {
|
||||
const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null];
|
||||
prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null;
|
||||
rec[nm.of] = prevPair;
|
||||
}
|
||||
}
|
||||
rows.push(rec);
|
||||
}
|
||||
return {
|
||||
key: "",
|
||||
name: prev.name,
|
||||
base: "",
|
||||
source: "",
|
||||
no: "",
|
||||
conds,
|
||||
vals,
|
||||
rule: null,
|
||||
rows,
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조.
|
||||
견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_Build.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "plan") {
|
||||
const plan = m.planLookup(job.info, job.roles);
|
||||
const cells = { ...m.blockCells(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 === "reapply") {
|
||||
const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [],
|
||||
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] },
|
||||
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
|
||||
const opts = { ref: "T1", fillBlock: true, outside: false };
|
||||
const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl;
|
||||
const cell = first.nodes[0].pillsIn[0].cell;
|
||||
first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" };
|
||||
const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl;
|
||||
return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []}
|
||||
EXACT = {
|
||||
**R,
|
||||
"conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
|
||||
"vals": {"석공": "인", "보통": "인"},
|
||||
"rows": [
|
||||
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12},
|
||||
{"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5},
|
||||
],
|
||||
}
|
||||
RATE = {
|
||||
**R,
|
||||
"conds": {"높이": "범위"},
|
||||
"vals": {"증가율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [
|
||||
{"높이": [3, 4], "증가율": 30},
|
||||
{"높이": [4, 5.5], "증가율": 40},
|
||||
{"높이": [5.5, None], "증가율": 60},
|
||||
],
|
||||
}
|
||||
MIXED = {
|
||||
**R,
|
||||
"conds": {"구분": "고르기", "높이": "범위"},
|
||||
"vals": {"율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [
|
||||
{"구분": "가", "높이": [0, 2], "율": 1},
|
||||
{"구분": "가", "높이": [2, 4], "율": 2},
|
||||
{"구분": "나", "높이": [0, 3], "율": 5},
|
||||
],
|
||||
}
|
||||
MINE = {
|
||||
**R,
|
||||
"conds": {"경사": "고르기"},
|
||||
"vals": {"할증": "%"},
|
||||
"rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}],
|
||||
}
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict:
|
||||
"""견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}."""
|
||||
roles = {"cond": cond or list(info["conds"]), "out": out}
|
||||
plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0]
|
||||
book = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "t",
|
||||
"서식": [{}],
|
||||
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
|
||||
"활성": "s1",
|
||||
}
|
||||
values = recalc_workbooks([book])[0]["s1"]
|
||||
p = plan["plan"]
|
||||
|
||||
def got(a1: str):
|
||||
v = values.get(a1) or {}
|
||||
return v.get("수", v.get("글", v.get("오류")))
|
||||
|
||||
res = {c: got(a) for c, a in p["outCells"].items()}
|
||||
return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res}
|
||||
|
||||
|
||||
def test_같음_조건_셋은_열쇠로_찾음():
|
||||
r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"])
|
||||
assert r["상태"] == "찾음" and r["줄"] == "3"
|
||||
assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21
|
||||
|
||||
|
||||
def test_빈칸은_무관_줄로_찾음():
|
||||
r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"])
|
||||
assert r["상태"] == "찾음" and float(r["석공"]) == 0.1
|
||||
|
||||
|
||||
def test_맞는_줄_없음과_둘_이상():
|
||||
none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"])
|
||||
assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A"
|
||||
dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"])
|
||||
assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A"
|
||||
|
||||
|
||||
def test_범위_조건은_아래_위_두_칸으로_초과_이하():
|
||||
assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하)
|
||||
assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40
|
||||
assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음
|
||||
assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님
|
||||
assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음"
|
||||
|
||||
|
||||
def test_같음_조건과_범위_조건이_섞임():
|
||||
assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2
|
||||
assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5
|
||||
assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음"
|
||||
assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음"
|
||||
|
||||
|
||||
def test_내_표_경사에서_할증_두_줄():
|
||||
assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10
|
||||
assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0
|
||||
|
||||
|
||||
def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜():
|
||||
r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"])
|
||||
assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋
|
||||
|
||||
|
||||
def test_붙여넣기_읽기():
|
||||
text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10"
|
||||
out = run([{"op": "paste", "text": text}])[0]
|
||||
info = out["info"]
|
||||
assert out["error"] is None
|
||||
assert info["conds"] == {"경사": "고르기", "범위": "범위"}
|
||||
assert info["vals"] == {"할증": ""}
|
||||
assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10}
|
||||
assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"]
|
||||
|
||||
|
||||
def test_다시_짜도_선_수식은_그대로():
|
||||
out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0]
|
||||
assert out["kept"] == {"식": "'다른 시트'!B2"}
|
||||
assert out["pills"] == ["경사"] and out["name"] == "표 · 견본"
|
||||
|
||||
|
||||
def test_표가_비었어도_수식이_깨지지_않음():
|
||||
"""내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨."""
|
||||
empty = {**MINE, "rows": [{}, {}]}
|
||||
r = solve(empty, {"경사": "급"}, ["할증"])
|
||||
assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A"
|
||||
Reference in New Issue
Block a user