diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.css b/M01_MasterData/SheetLogic/M01_SL_Table.css new file mode 100644 index 000000000..582c89597 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table.css @@ -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); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.ts b/M01_MasterData/SheetLogic/M01_SL_Table.ts new file mode 100644 index 000000000..8d046d340 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table.ts @@ -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 => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +/* ── 서버 읽기 — 절 · 표 ── */ +async function get(path: string, params: Record): Promise { + 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; + 값칸: Record; + count: number; +} +interface RawTable { + 키: string; + 원문번호: string; + 이름: string; + 기준: string; + 출처: string; + 조건: Record; + 값칸: Record; + 범위규칙?: string | null; + 줄: TblInfo["rows"]; + 주?: string[]; +} + +const BOOKS = ["산림품셈", "건설품셈"]; +const cache = new Map(); +const fileOf = new Map(); + +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 { + 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 | 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 => { + 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 => { + 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 => { + 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; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts new file mode 100644 index 000000000..8f8984437 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -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; + +/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */ +export interface TblInfo { + key: string; + name: string; + /** 기준 「1㎡」 */ + base: string; + source: string; + /** 원문번호 「13-4-1」 */ + no: string; + /** 조건 칸 → 「수」 「고르기」 「범위」 */ + conds: Record; + /** 값 칸 → 단위 */ + vals: Record; + 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; + /** 조건 칸 이름 → 입력 칸 */ + inCells: Record; + /** 결과 칸 이름 → 출력 칸 */ + outCells: Record; + 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 { + const cells: Record = {}; + 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 = {}; + 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 = {}; + const inputOf: Record = {}; + 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(${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 = {}; + 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 = {}; + const vals: Record = {}; + 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 { + 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 = {}; + const vals: Record = {}; + 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: [], + }; +} diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py new file mode 100644 index 000000000..2676fd680 --- /dev/null +++ b/resources/tester/test_m01_sl_table_build.py @@ -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"