diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts index d8ac397c7..3398ed630 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.ts @@ -17,8 +17,8 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; -import { slList, type SlListItem } from "./M01_SL_Api"; -import { newLogic, type SheetLogic } from "./M01_SL_Model"; +import type { SlListItem } from "./M01_SL_Api"; +import type { SheetLogic } from "./M01_SL_Model"; import "./M01_SL_Entry.css"; const TEXT = { @@ -76,15 +76,12 @@ const amountOf = (resultUnit: string): string => { }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -/* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ -const loadMine = (): Promise => slList().catch(() => []); -const blankLogic = (name: string, unit: string): SheetLogic | null => { - try { - return newLogic(name, unit); - } catch { - return null; - } -}; +/* SheetLogic 파일은 늦게 불러옴(import()) — 스프레드시트 엔진이 M01 첫 묶음에 안 들어가게. + * ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 빈 목록 · null 로 대신함 — 채워지면 catch 를 지움 */ +const loadMine = (): Promise => + import("./M01_SL_Api").then((m) => m.slList()).catch(() => []); +const blankLogic = (name: string, unit: string): Promise => + import("./M01_SL_Model").then((m) => m.newLogic(name, unit)).catch(() => null); /** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createSheetLogicButton(): HTMLButtonElement { @@ -235,7 +232,9 @@ function openSheetLogic(): void { const showPaper = (): void => { let dirty = false; const units = unitChoices(); - paper = { dirty: () => dirty, sl: blankLogic("", units[0]) }; + const mine: { dirty(): boolean; sl: SheetLogic | null } = { dirty: () => dirty, sl: null }; + paper = mine; + void blankLogic("", units[0]).then((sl) => (mine.sl = sl)); const unsaved = createTag(te("Unsaved"), "warning"); unsaved.hidden = true; diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts index 69bc12453..0dd408093 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Eval.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -54,6 +54,11 @@ const TEXT = { "Outside value 「{pill}」 of 「{box}」 not filled yet", ], NoResult: ["결과 상자가 없음", "There is no result box"], + /** {box} */ + HeadChanged: [ + "「{box}」 표 머리 줄이 바뀜 — 표를 다시 골라야 함", + "Table headers of 「{box}」 changed — choose the table again", + ], /** {cell} {why} */ NoAnswer: [ "{cell} 칸 바깥 값을 못 받음 {why}", @@ -102,6 +107,7 @@ export interface SlOutsideAnswer { export function answersToValues( cells: SlOutside[], answers: SlOutsideAnswer[], + sl?: SheetLogic, ): { values: SlOutsideValue[]; errors: string[] } { const values: SlOutsideValue[] = []; const errors: string[] = []; @@ -111,11 +117,29 @@ export function answersToValues( errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" })); continue; } + const head = sl && a.rows ? headChanged(sl, c, a.rows[0] ?? []) : null; + if (head) errors.push(head); values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); } return { values, errors }; } +/** 표 블록 머리 줄이 시트에 있던 것과 다르면 쉬운 말(열 차례가 바뀌면 찾기 판이 엉뚱한 열을 봄) */ +function headChanged( + sl: SheetLogic, + c: SlOutside, + head: (string | number | null)[], +): string | null { + const sheet = nodeSheet(sl, c.node); + const at = parseA1(c.cell); + if (!sheet || !at) return null; + const now = head.map((_, dc) => sheet.칸[toA1(at.r, at.c + dc)]?.값 ?? null); + if (now.every((x) => x === null)) return null; // 처음 채움 + const same = head.length === now.length && head.every((h, i) => h === now[i]); + const box = sl.nodes.find((n) => n.id === c.node)?.name ?? ""; + return same ? null : tl("HeadChanged", { box }); +} + /** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ function putValue(칸: Record, cell: string, value: SlValue): void { const style = 칸[cell]?.서식; @@ -139,6 +163,15 @@ export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLog v.value.forEach((line, dr) => line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)), ); + // 표가 줄었으면 블록 열 아래 남은 옛 줄(값 칸)을 지움 — 수식 칸(찾기 판 · 열쇠)은 블록 열 밖이라 안 건드림 + const width = Math.max(0, ...v.value.map((line) => line.length)); + const last = at.r + v.value.length - 1; + for (const a of Object.keys(sheet.칸)) { + const p = parseA1(a); + const c = sheet.칸[a]; + if (p && p.r > last && p.c >= at.c && p.c < at.c + width && c.식 === undefined) + putValue(sheet.칸, a, null); + } } return out; } diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts new file mode 100644 index 000000000..1b5639b87 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -0,0 +1,691 @@ +/* ============================================================================= + * M01_SL_Import.ts + * 시트 로직 — 기존 로직 가져오기(정함 9) · 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · DOM 없음 + * + * 읽기 = LogicBuilder Blocks(`readLogic`)로 입력값을 받고 · 식은 엔진 문법(`master_formula`) 그대로 읽어 + * 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · + * == → = · != → <> · 그리고/또는 → AND/OR · 찾기 → 표 상자 · 로직 → 다른 로직 상자 · 노임 키 → 바깥 값. + * 다른 상자 값은 받는 상자에 입력 알약 한 칸(「'시트'!칸」 참조 수식) + 선 하나 — 식은 제 상자 칸만 봄. + * + * 상자 시트 꼴 — A 이름 · B 값 · C 설명(1행 머리) · 알약은 B 칸. + * 표 상자 = 조건 알약(B) · 결과 알약(B) · E1 부터 품셈 표(머리 줄 + 줄 · 조건 칸 차례 → 값 칸 차례 · + * 범위 조건은 「이름·아래」 「이름·위」 두 칸) · 그 오른쪽에 「맞음」 칸(줄마다 조건 셈 → 1/0) · + * 결과 = 맞는 줄이 하나일 때 INDEX/MATCH · 아니면 #N/A(엔진 `find` 의 해당 없음 · 중복). + * 빈 조건 칸은 무엇이든 맞음 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하)은 엔진과 같음. + * 바깥 값(노임 · 자재 · 장비 · 로직 · 표)은 알약 `outside` · 실제 값은 열 때 · 저장 때 넣음(`outsideOf`). + * 못 바꾸는 꼴 = 그 칸 #N/A + 칸 메모(원래 글) + 쉬운 말 한 줄(`problems`). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import type { + CalcValues, + Cell, + Sheet, + Workbook, +} from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { readLogic } from "../LogicBuilder/M01_LB_Blocks"; +import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model"; +import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; +import { + parseFormula, + RULES, + tableCols, + tableRows, + type Ast, + type SlImportTable, +} from "./M01_SL_Import_Parse"; + +const TEXT = { + Name: ["이름", "Name"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Qty: ["수량", "Quantity"], + Price: ["단가", "Unit price"], + Amount: ["금액", "Amount"], + Result: ["결과", "Result"], + Sum: ["항목 합", "Item total"], + Input: ["입력값", "Input"], + Table: ["표", "Table"], + Calc: ["계산", "Calc"], + Item: ["항목", "Item"], + Logic: ["다른 로직", "Other logic"], + Const: ["고정값", "Constant"], + Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], + Match: ["맞음", "Match"], + Pass: ["넘길 값", "Passed value"], + Cant: ["못 바꿈 — ", "Not converted — "], + NoTable: ["품셈 표 「{v}」 를 못 받음", "Table “{v}” was not given"], + NoCol: ["표에 없는 칸 「{v}」", "Column “{v}” is not in the table"], + Rule: ["모르는 범위규칙 「{v}」", "Unknown range rule “{v}”"], + Unknown: ["모르는 이름 「{v}」", "Unknown name “{v}”"], + Hole: ["입력에 따라 바뀌는 요소 「{v}」", "Element changes with an input “{v}”"], + Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"], + FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"], + Parse: ["읽을 수 없는 글", "Text that can't be read"], +} as const; +const t = (k: keyof typeof TEXT, v = ""): string => + TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); + +export type { SlImportTable }; + +export interface SlImportOptions { + /** 표 키 → 품셈 표(열 때 받은 것) — 없으면 그 표 상자는 못 바꿈 */ + tables?: Record; + /** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */ + examples?: Record; +} + +export interface SlImportReport { + sl: SheetLogic; + /** 못 바꾼 꼴(쉬운 말) — 비면 다 바뀜 */ + problems: string[]; +} + +/* ── 짓기 ───────────────────────────────────────────────────────────────── */ + +class Fail extends Error {} +const KEY = /^[A-Z]{2}\d{6}$/; +const COSTS = ["노무비", "재료비", "경비"] as const; +type Cost = (typeof COSTS)[number]; +const lit = (v: unknown): string => + typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; +const cellOf = (v: unknown): Cell | null => + typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? { 값: v } : null; + +/** 표 상자 하나 = 표 + 조건 묶음 하나(같은 표 · 같은 조건이면 결과 알약만 더함) */ +interface TableBox { + node: SlNode; + table: SlImportTable; + /** 결과 칸 이름 → 표 열 글자(범위 칸은 아래 · 위) */ + colOf: Map; + rows: number; + /** 다음 빈 열(맞음 · 결과 셈 칸) */ + free: number; + /** 결과 셈에 쓸 「맞음」 열(엔진 `find` 의 위 끝 고르기까지) */ + hit: string; +} + +class Build { + readonly book: Workbook = { 종류: "통합문서", 판: 1, 열: "sheet-logic", 서식: [{}], 시트: [] }; + readonly nodes: SlNode[] = []; + readonly edges: SlEdge[] = []; + readonly problems: string[] = []; + private readonly next = new Map(); + private readonly sheets = new Map(); + private readonly linked = new Map(); + readonly inputs = new Map(); + readonly mids = new Map(); + readonly tables = new Map(); + readonly logics = new Map(); + readonly consts = new Map(); + + constructor(readonly opts: SlImportOptions) {} + + node(kind: SlKind, name: string, stage: number, ref?: string): SlNode { + const n = this.nodes.length + 1; + const head = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore + const base = kind === "result" ? t("Result") : `${t(head)} · ${name}`.slice(0, 31); + let title = base; + for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++) + title = `${base.slice(0, 28)} ${k}`; + const sheet: Sheet = { + id: `s${n}`, + 이름: title, + 칸: { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } }, + }; + this.book.시트.push(sheet); + this.sheets.set(sheet.id, sheet); + const node: SlNode = { + id: `n${n}`, + kind, + name, + sheetId: sheet.id, + pillsIn: [], + pillsOut: [], + stage, + ...(ref ? { ref } : {}), + }; + this.nodes.push(node); + this.next.set(node.id, 2); + return node; + } + + sheet(node: SlNode): Sheet { + return this.sheets.get(node.sheetId)!; + } + + /** 줄 하나 더함 — A 이름 · B 값(또는 식) · C 설명 → B 칸 주소 */ + row(node: SlNode, label: string, cell: Cell, note = ""): string { + const r = this.next.get(node.id)!; + this.next.set(node.id, r + 1); + const s = this.sheet(node); + s.칸[`A${r}`] = { 값: label }; + s.칸[`B${r}`] = cell; + if (note) s.칸[`C${r}`] = { 값: note }; + return `B${r}`; + } + + pill(node: SlNode, dir: "in" | "out", pill: SlPill): void { + const list = dir === "in" ? node.pillsIn : node.pillsOut; + let name = pill.name; + for (let k = 2; list.some((p) => p.name === name); k++) name = `${pill.name} ${k}`; + list.push({ ...pill, name }); + } + + /** 출력 알약 하나 — 같은 이름이 있으면 그 칸 */ + out(node: SlNode, name: string, cell: Cell, note = ""): string { + const had = node.pillsOut.find((p) => p.name === name); + if (had) return had.cell; + const at = this.row(node, name, cell, note); + this.pill(node, "out", { name, cell: at }); + return at; + } + + /** 다른 상자 출력 알약 → 이 상자 입력 알약 한 칸 + 선 · 이 상자 안 칸 주소 + * `into` = 이미 있는 입력 알약(넘길 값 칸)에 바로 이음 */ + link(to: SlNode, from: SlNode, pill: string, into?: SlPill): string { + const id = `${to.id}<${from.id}:${pill}`; + const had = this.linked.get(id); + if (had && !into) return had; + const src = from.pillsOut.find((p) => p.name === pill)!; + const ref: Cell = { 식: `${quoteSheet(this.sheet(from).이름)}!${src.cell}` }; + let target = into; + if (target) this.sheet(to).칸[target.cell] = ref; + else { + const name = from.kind === "input" || from.kind === "calc" ? pill : `${pill} · ${from.name}`; + this.pill(to, "in", { name, cell: this.row(to, name, ref) }); + target = to.pillsIn[to.pillsIn.length - 1]; + } + this.edges.push({ + id: `e${this.edges.length + 1}`, + from: { node: from.id, pill }, + to: { node: to.id, pill: target.name }, + }); + if (!had) this.linked.set(id, target.cell); + return target.cell; + } + + /** 못 바꾸는 꼴 — 칸 #N/A + 메모(원래 글) + 쉬운 말 */ + cant(node: SlNode, at: string, why: string, src: string): void { + const s = this.sheet(node); + s.칸[at] = { 식: "NA()" }; + (s.comments ??= {})[at] = src; + s.칸[`C${at.slice(1)}`] = { 값: `${t("Cant")}${why}` }; + this.problems.push(`${node.name} — ${why}`); + } +} + +/* ── 식 → 엑셀 ───────────────────────────────────────────────────────────── */ + +interface Scope { + b: Build; + /** 식이 놓일 상자 */ + here: SlNode; + /** 덧줄 — 비목 합(앞 덧줄까지) 을 주는 상자 · 알약 */ + costs?: Record; + /** 덧줄 — 앞 덧줄 이름 · 줄.'이름' */ + extras?: Map; + lines?: Map; +} + +function excel(n: Ast, sc: Scope): string { + const { b, here } = sc; + const wrap = (x: Ast): string => (x.t === "bin" ? `(${excel(x, sc)})` : excel(x, sc)); + switch (n.t) { + case "num": + return n.v; + case "str": + return lit(n.v); + case "neg": + return `-${wrap(n.a)}`; + case "eref": + throw new Fail(t("Hole", n.v)); + case "bin": { + if (n.op === "그리고" || n.op === "또는") + return `${n.op === "그리고" ? "AND" : "OR"}(${excel(n.a, sc)},${excel(n.b, sc)})`; + const op = n.op === "==" ? "=" : n.op === "!=" ? "<>" : n.op; + return `${wrap(n.a)}${op}${wrap(n.b)}`; + } + case "call": { + const items = n.items.map((x) => excel(x, sc)); + if (n.f === "만약") return `IF(${items.join(",")})`; + if (n.f === "최소" || n.f === "최대") + return `${n.f === "최소" ? "MIN" : "MAX"}(${items.join(",")})`; + const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[n.f]!; + return `${fn}(${items[0]},${items[1] ?? "0"})`; + } + case "find": + throw new Fail(t("FindAll")); + case "logic": + return b.link(here, logicBox(b, n), t("Result")); + case "field": { + const a = n.a; + if (a.t === "find") return b.link(here, ...findBox(b, a, n.f)); + if (a.t === "logic") return b.link(here, logicBox(b, a, n.f), n.f); + if (a.t === "name" && a.v === "줄" && sc.lines?.has(n.f)) + return b.link(here, ...sc.lines.get(n.f)!); + throw new Fail(t("Field", `${a.t === "name" ? a.v : ""}.${n.f}`)); + } + case "name": { + const v = n.v; + if (b.inputs.has(v)) return b.link(here, b.inputs.get(v)!, v); + if (b.mids.has(v)) return b.link(here, b.mids.get(v)!, v); + if (sc.extras?.has(v)) return b.link(here, ...sc.extras.get(v)!); + if (sc.costs && (COSTS as readonly string[]).includes(v)) { + const refs = sc.costs[v as Cost].map(([node, pill]) => b.link(here, node, pill)); + return refs.length ? `(${refs.join("+")})` : "0"; + } + if (KEY.test(v)) return b.link(here, constBox(b, v), t("Price")); + throw new Fail(t("Unknown", v)); + } + } +} + +/** 식 글 하나를 상자 칸에 — 못 바꾸면 #N/A + 메모 */ +function put(sc: Scope, label: string, text: string, pill: "out" | null, note = ""): string { + const ast = parseFormula(text); + let cell: Cell; + let why = ""; + try { + if (!ast) throw new Fail(t("Parse")); + cell = ast.t === "num" ? { 값: Number(ast.v) } : { 식: excel(ast, sc) }; + } catch (e) { + if (!(e instanceof Fail)) throw e; + why = e.message; + cell = { 식: "NA()" }; + } + const at = + pill === "out" ? sc.b.out(sc.here, label, cell, note) : sc.b.row(sc.here, label, cell, note); + if (why) sc.b.cant(sc.here, at, why, text); + return at; +} + +/** 엔진 노임 키(식 속 LB000006 등) → 고정값 상자 · 바깥 값 알약 */ +function constBox(b: Build, key: string): SlNode { + const had = b.consts.get(key); + if (had) return had; + const node = b.node("const", key, 0, key); + const at = b.row(node, t("Price"), {}, t("Outside")); + b.pill(node, "in", { name: t("Price"), cell: at, outside: true }); + b.pill(node, "out", { name: t("Price"), cell: at }); + b.consts.set(key, node); + return node; +} + +/** 넘길 값(로직 부름 인자 · 표 조건) → 입력 알약 — 글 · 수는 그 칸에 값 · 입력값 · 계산 이름은 선 · 식은 셈 */ +function args(b: Build, node: SlNode, list: [string, Ast][]): void { + for (const [k, v] of list) { + const at = b.row(node, k, {}, t("Pass")); + b.pill(node, "in", { name: k, cell: at }); + const pill = node.pillsIn[node.pillsIn.length - 1]; + const s = b.sheet(node); + try { + if (v.t === "num") s.칸[at] = { 값: Number(v.v) }; + else if (v.t === "str") s.칸[at] = { 값: v.v }; + else if (v.t === "name" && b.inputs.has(v.v)) b.link(node, b.inputs.get(v.v)!, v.v, pill); + else if (v.t === "name" && b.mids.has(v.v)) b.link(node, b.mids.get(v.v)!, v.v, pill); + else s.칸[at] = { 식: excel(v, { b, here: node }) }; + } catch (e) { + if (!(e instanceof Fail)) throw e; + b.cant(node, at, e.message, k); + } + } +} + +/** 다른 로직 상자(식 속 `로직(키, …)`) — 결과 · 비목 칸은 바깥 값 */ +function logicBox(b: Build, n: Extract, field?: string): SlNode { + const sig = n.src.replace(/\s+/g, ""); + let node = b.logics.get(sig); + if (!node) { + if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head)); + node = b.node("logic", n.head, 2, n.head); + args(b, node, n.args); + b.logics.set(sig, node); + } + const name = field ?? t("Result"); + if (!node.pillsOut.some((p) => p.name === name)) { + const at = b.row(node, name, {}, t("Outside")); + b.pill(node, "in", { name, cell: at, outside: true }); + b.pill(node, "out", { name, cell: at }); + } + return node; +} + +/* ── 표 상자 ─────────────────────────────────────────────────────────────── */ + +/** 표 상자 — 같은 표 · 같은 조건이면 결과 알약만 더함 → [상자, 결과 알약 이름] */ +function findBox(b: Build, n: Extract, col: string): [SlNode, string] { + const sig = `${n.head}|${n.args.map(([k]) => k).join(",")}|${n.src.replace(/\s+/g, "").replace(/\)\..*$/, "")}`; + let box = b.tables.get(sig); + if (!box) box = newTable(b, n, sig); + if (!box.colOf.has(col)) throw new Fail(t("NoCol", col)); + if (!box.node.pillsOut.some((p) => p.name === col)) resultCol(b, box, col); + return [box.node, col]; +} + +function newTable(b: Build, n: Extract, sig: string): TableBox { + const table = b.opts.tables?.[n.head]; + if (!table) throw new Fail(t("NoTable", n.head)); + const rule = String(table.범위규칙 ?? "초과·이하") + .split(" · ")[0] + .split(" (")[0]; + for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k)); + if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule)) + throw new Fail(t("Rule", String(table.범위규칙))); + const node = b.node("table", table.이름 || n.head, 1, n.head); + const s = b.sheet(node); + // 조건 알약 — 받는 값은 넘긴 식 + const cond = new Map(); + args(b, node, n.args); + for (const [k] of n.args) cond.set(k, node.pillsIn.find((p) => p.name === k)!.cell); + // 표 E1 부터 + const cols = tableCols(table); + const rows = tableRows(table); + b.row(node, t("Table"), { 값: n.head }, t("Outside")); + // 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움 + b.pill(node, "out", { name: "표", cell: "E1", outside: true }); + rows.forEach((r, i) => + r.forEach((v, j) => { + const c = cellOf(v); + if (c) s.칸[`${colName(4 + j)}${i + 1}`] = c; + }), + ); + const letter = (key: string, part: "v" | "lo" | "hi"): string => + colName(4 + cols.findIndex((c) => c.key === key && c.part === part)); + const N = table.줄.length; + const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; + let free = 4 + cols.length + 1; + // 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로) + const M = colName(free++); + s.칸[`${M}1`] = { 값: t("Match") }; + const top = Math.max( + ...table.줄.flatMap((r) => + Object.keys(table.조건) + .filter((k) => table.조건[k] === "범위" && Array.isArray(r[k])) + .map((k) => (r[k] as (number | null)[])[1] ?? -Infinity), + ), + ); + const closed = String(table.범위규칙 ?? "").includes("이하 포함"); + for (let r = 2; r <= N + 1; r++) { + const parts = [...cond].map(([k, pin]) => { + const x = `$${pin.slice(0, 1)}$${pin.slice(1)}`; + if (table.조건[k] !== "범위") { + const X = `${letter(k, "v")}${r}`; + return `OR(${X}="",${X}=${x})`; + } + const L = `${letter(k, "lo")}${r}`; + const H = `${letter(k, "hi")}${r}`; + const low = rule === "이상·이하" || rule === "이상·미만" ? ">=" : ">"; + const up = + rule === "이상·미만" + ? `OR(${H}="",${x}<${H}${closed ? `,AND(${H}=${top},${x}=${H})` : ""})` + : `OR(${H}="",${x}<=${H})`; + return `AND(OR(${L}="",${x}${low}${L}),${up})`; + }); + s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" }; + } + // 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`) + let hit = M; + const ranged = [...cond.keys()].filter((k) => table.조건[k] === "범위"); + if ((rule === "이하" || rule === "이상·이하") && ranged.length) { + hit = colName(free++); + s.칸[`${hit}1`] = { 값: `${t("Match")} 2` }; + for (let r = 2; r <= N + 1; r++) { + const least = ranged.map((k) => { + const H = letter(k, "hi"); + return `OR(${H}${r}="",${H}${r}=MINIFS(${range(H)},${range(M)},1))`; + }); + s.칸[`${hit}${r}`] = { + 식: `IF(AND(${M}${r}=1,OR(COUNTIF(${range(M)},1)<2,${least.join(",")})),1,0)`, + }; + } + } + const colOf = new Map(); + cols.forEach((c, j) => { + if (c.part === "hi") colOf.set(c.key, { ...colOf.get(c.key)!, hi: colName(4 + j) }); + else if (!colOf.has(c.key)) colOf.set(c.key, { x: colName(4 + j) }); + }); + const box: TableBox = { node, table, colOf, rows: N, free, hit }; + b.tables.set(sig, box); + return box; +} + +/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A · + * 범위 값 칸은 아래 = 위(숫자 하나)일 때만 값 · 아니면 #N/A(엔진 「값이 범위」) */ +function resultCol(b: Build, box: TableBox, col: string): void { + const s = b.sheet(box.node); + const N = box.rows; + const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; + const { x: X, hi } = box.colOf.get(col)!; + const W = colName(box.free++); + s.칸[`${W}1`] = { 값: `${t("Match")} · ${col}` }; + for (let r = 2; r <= N + 1; r++) + s.칸[`${W}${r}`] = { + 식: `IF(AND(${box.hit}${r}=1,OR(COUNTIF(${range(box.hit)},1)<2,${X}${r}<>"")),1,0)`, + }; + const at = (L: string): string => `INDEX(${range(L)},MATCH(1,${range(W)},0))`; + const value = hi ? `IF(${at(X)}=${at(hi)},${at(X)},NA())` : at(X); + b.out(box.node, col, { 식: `IF(COUNTIF(${range(W)},1)=1,${value},NA())` }); +} + +/* ── 상자들 ─────────────────────────────────────────────────────────────── */ + +const example = (spec: LogicInput, given?: number | string): Cell => { + if (given !== undefined) return { 값: given }; + if (spec.고르기?.length) return { 값: spec.고르기[0] }; + if (spec.범위) return { 값: spec.범위[0] }; + return { 값: 0 }; +}; + +function inputBox(b: Build, spec: LogicInput): void { + const node = b.node("input", spec.이름, 0); + const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? ""); + const r = Number(at.slice(1)) - 1; + const whole = { r0: r, c0: 1, r1: r, c1: 1 }; + if (spec.고르기?.length) + b.sheet(node).유효성 = [ + { + id: "v1", + 범위: [whole], + 규칙: { 종류: "목록", 값들: spec.고르기.map(String) }, + 어기면: "막음", + }, + ]; + else if (spec.범위) + b.sheet(node).유효성 = [ + { + id: "v1", + 범위: [whole], + 규칙: { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] }, + 어기면: "막음", + }, + ]; + const pill = node.pillsOut[0]; + if (spec.단위) pill.unit = spec.단위; + b.inputs.set(spec.이름, node); +} + +/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */ +function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | number { + const ast = parseFormula(item.수량); + if (ast?.t === "num") return Number(ast.v); + if (ast?.t === "name" && b.inputs.has(ast.v)) return [b.inputs.get(ast.v)!, ast.v]; + if (ast?.t === "name" && b.mids.has(ast.v)) return [b.mids.get(ast.v)!, ast.v]; + const label = `${name} ${t("Qty")}`; + const node = b.node("calc", label, 2); + put({ b, here: node }, label, item.수량, "out"); + return [node, label]; +} + +/** 항목 상자(호표 줄) → 비목 출력 알약들 */ +function itemBox(b: Build, item: HoLine, lines: Map): [SlNode, Cost][] { + const name = item.이름 || String(item.요소); + const element = typeof item.요소 === "string" ? item.요소 : JSON.stringify(item.요소); + const call = + typeof item.요소 === "string" && item.요소.startsWith("로직(") ? parseFormula(item.요소) : null; + const node = b.node("item", name, 3, call?.t === "logic" ? (call.args.some(([k]) => k === "기계") ? element : call.head) : element); // prettier-ignore + const q = qtyFrom(b, item, name); + const qty = + typeof q === "number" + ? b.row(node, t("Qty"), { 값: q }, item.단위 ?? "") + : b.link(node, q[0], q[1]); + if (typeof q === "number") b.pill(node, "in", { name: t("Qty"), cell: qty, unit: item.단위 }); + const out: [SlNode, Cost][] = []; + if (call?.t === "logic") { + args(b, node, call.args); + const unit = COSTS.map((k) => { + const at = b.row(node, `${t("Price")} · ${k}`, {}, t("Outside")); + b.pill(node, "in", { name: k, cell: at, outside: true }); + return at; + }); + const fixed = item.비목 as Cost | undefined; + const total = `${qty}*(${unit.join("+")})`; + if (fixed && (COSTS as readonly string[]).includes(fixed)) { + b.out(node, fixed, { 식: total }); + out.push([node, fixed]); + lines.set(name, [node, fixed]); + } else { + COSTS.forEach((k, i) => { + b.out(node, k, { 식: `${qty}*${unit[i]}` }); + out.push([node, k]); + }); + b.out(node, t("Amount"), { 식: total }); + lines.set(name, [node, t("Amount")]); + } + return out; + } + const price = b.row(node, t("Price"), {}, t("Outside")); + b.pill(node, "in", { name: t("Price"), cell: price, outside: true }); + const holes = typeof item.요소 === "string" ? [item.요소] : Object.values(item.요소 ?? {}); + if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v))) + b.cant(node, price, t("Hole", element), element); + const cost = ( + (COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비" + ) as Cost; + b.out(node, cost, { 식: `${qty}*${price}` }); + lines.set(name, [node, cost]); + return [[node, cost]]; +} + +type Cut = { 대상?: string; 자리?: number; 방법?: string }; +const cutText = (row: LogicRow, on: string, x: string): string => { + const rule = row.끝수 as unknown as Cut | null; + if (!rule || typeof rule !== "object" || rule.대상 !== on) return x; + const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[String(rule.방법)]; + return fn ? `${fn}(${x},${rule.자리 ?? 0})` : x; +}; + +/* ── 밖으로 ─────────────────────────────────────────────────────────────── */ + +/** 기존 로직 → 시트 로직 + 못 바꾼 꼴 */ +export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): SlImportReport { + const lb = readLogic(row, { key: row.키 || null, version: null, isBase: true }); + const b = new Build(opts); + for (const spec of lb.inputs) inputBox(b, spec); + for (const m of row.중간 ?? []) b.mids.set(m.이름, b.node("calc", m.이름, 2)); + for (const m of row.중간 ?? []) + put({ b, here: b.mids.get(m.이름)! }, m.이름, m.식, "out", m.출처 ?? ""); + const result = b.node("result", t("Result"), 4); + if (row.결과) { + const at = put({ b, here: result }, t("Result"), row.결과.식, null); + const s = b.sheet(result); + if (s.칸[at].식 && s.칸[at].식 !== "NA()") + s.칸[at] = { 식: cutText(row, "결과", s.칸[at].식!) }; + else if (s.칸[at].값 !== undefined) + s.칸[at] = { 식: cutText(row, "결과", String(s.칸[at].값)) }; + b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 }); + } else { + const lines = new Map(); + const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines)); + // 항목 합 — 덧줄이 「노무비」 등으로 부르는 호표 줄 비목 합 + const sum = b.node("calc", t("Sum"), 3); + const costs = {} as Record; + for (const k of COSTS) { + const refs = outs.filter(([, c]) => c === k).map(([n, c]) => b.link(sum, n, c)); + b.out(sum, k, refs.length ? { 식: `SUM(${refs.join(",")})` } : { 값: 0 }); + costs[k] = [[sum, k]]; + } + const extras = new Map(); + for (const extra of row.덧줄 ?? []) { + const cost = ( + (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" + ) as Cost; + const node = b.node("item", extra.이름, 3, "비율"); + put({ b, here: node, costs, extras, lines }, cost, extra.식, "out", extra.출처 ?? ""); + extras.set(extra.이름, [node, cost]); + costs[cost] = [...costs[cost], [node, cost]]; + } + const total: string[] = []; + for (const k of COSTS) { + const refs = costs[k].map(([n, c]) => b.link(result, n, c)); + const at = b.out(result, k, { + 식: cutText(row, "계", refs.length > 1 ? `SUM(${refs.join(",")})` : refs[0]), + }); + total.push(at); + } + b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위); + } + b.book.활성 = b.book.시트[0]?.id; + const sl: SheetLogic = { + key: null, + name: row.이름, + resultUnit: row.결과단위, + workbook: b.book, + nodes: b.nodes, + edges: b.edges, + }; + return { sl, problems: b.problems }; +} + +/** 기존 로직 → 시트 로직(정함 9) */ +export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLogic { + return importLogicReport(row, opts).sl; +} + +/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ +function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { + const got = calc?.[sheetId]?.[cell]; + if (got && "수" in got) return Number(got.수); + if (got && "글" in got) return got.글; + if (got && "참" in got) return got.참; + return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; +} + +/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ +export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { + const out: SlOutsideAt[] = []; + for (const node of sl.nodes) { + const passed: Record = {}; + const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + for (const p of node.pillsIn) { + const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; + if (node.kind !== "table" && TEXT.Pass.includes(note as never)) + passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); + } + const ref = node.ref ?? ""; + for (const p of outside) { + const id = `${node.id}:${p.name}`; + const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) + ? (p.name as SlField) + : undefined; + let item: SlOutside; + if (node.kind === "table") item = { id, kind: "표", table: ref }; + else if ( + node.kind === "logic" || + ref.startsWith("GF") || + ref.startsWith("GC") || + ref.startsWith("GX") + ) + item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("로직(")) + item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; + else item = { id, kind: "자재", key: ref }; + out.push({ item, sheet: node.sheetId, cell: p.cell }); + } + } + return out; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts new file mode 100644 index 000000000..0bdcffee6 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * M01_SL_Import_Parse.ts + * 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로 + * 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 · + * - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null + * 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +export type Ast = + | { t: "num"; v: string } + | { t: "str"; v: string } + | { t: "name"; v: string } + | { t: "eref"; v: string } + | { t: "neg"; a: Ast } + | { t: "field"; a: Ast; f: string } + | { t: "call"; f: string; items: Ast[] } + | { t: "find"; head: string; args: [string, Ast][]; src: string } + | { t: "logic"; head: string; args: [string, Ast][]; src: string } + | { t: "bin"; op: string; a: Ast; b: Ast }; + +const CMPS = ["<=", ">=", "==", "!=", "<", ">"]; +const CALLS = ["만약", "최소", "최대", "올림", "버림", "반올림"]; + +export function parseFormula(s: string): Ast | null { + let i = 0; + const fail = (): never => { + throw new Error("parse"); + }; + const ws = (): void => { + while (i < s.length && /\s/.test(s[i])) i++; + }; + const take = (w: string): boolean => { + ws(); + if (!s.startsWith(w, i)) return false; + i += w.length; + return true; + }; + const need = (w: string): void => void (take(w) || fail()); + const word = (): string => { + ws(); + const m = /[\p{L}\p{N}_]+/uy; + m.lastIndex = i; + const got = m.exec(s) ?? fail(); + i = m.lastIndex; + return got[0]; + }; + const quoted = (): string => { + const e = s.indexOf("'", i); + if (e < 0) fail(); + const v = s.slice(i, e); + i = e + 1; + return v; + }; + /** 로직 · 표 머리 글 — 「,」 「)」 에서 멈춤(괄호 · 중괄호 안은 넘김) */ + const head = (): string => { + ws(); + const st = i; + for (let d = 0; i < s.length; i++) { + const c = s[i]; + if ("({".includes(c)) d++; + else if (")}".includes(c)) { + if (!d) break; + d--; + } else if (!d && c === ",") break; + } + return s.slice(st, i).trim(); + }; + const fields = (n: Ast): Ast => { + while (take(".")) n = { t: "field", a: n, f: take("'") ? quoted() : word() }; + return n; + }; + const primary = (): Ast => { + ws(); + const st = i; + if (take("(")) { + const a = expr(); + need(")"); + return fields(a); + } + if (take("'")) return { t: "str", v: quoted() }; + const num = /\d+(?:\.\d+)?/y; + num.lastIndex = i; + const n = num.exec(s); + if (n) { + i = num.lastIndex; + return { t: "num", v: n[0] }; + } + const w = word(); + if (w === "찾기" || w === "로직") { + need("("); + const h = head(); + const args: [string, Ast][] = []; + while (take(",")) { + const k = take("'") ? quoted() : word(); + need("="); + args.push([k, expr()]); + } + need(")"); + const call = { head: h, args, src: s.slice(st, i) }; + return fields(w === "찾기" ? { t: "find", ...call } : { t: "logic", ...call }); + } + if (CALLS.includes(w)) { + need("("); + const items = [expr()]; + while (take(",")) items.push(expr()); + need(")"); + return fields({ t: "call", f: w, items }); + } + if (s[i] === ":") { + for (i++; i < s.length && !/[\s,)*/+-]/.test(s[i]); i++); + return { t: "eref", v: s.slice(st, i) }; + } + return fields({ t: "name", v: w }); + }; + const unary = (): Ast => (take("-") ? { t: "neg", a: unary() } : primary()); + const chain = (next: () => Ast, ops: string[], once = false): Ast => { + let n = next(); + for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) + n = { t: "bin", op, a: n, b: next() }; + return n; + }; + const mul = (): Ast => chain(unary, ["*", "/"]); + const add = (): Ast => chain(mul, ["+", "-"]); + const cmp = (): Ast => chain(add, CMPS, true); + const both = (): Ast => chain(cmp, ["그리고"]); + const expr = (): Ast => chain(both, ["또는"]); + try { + const node = expr(); + ws(); + return i === s.length ? node : null; + } catch { + return null; + } +} + +/* ── 품셈 표 칸 놓기 ─────────────────────────────────────────────────────── */ + +/** 가져올 때 같이 받는 품셈 표 — 서버 `/table` 답의 쓸 칸만 */ +export interface SlImportTable { + 키?: string; + 이름?: string; + 조건: Record; + 값칸: Record; + 범위규칙?: string | null; + 줄: Record[]; +} + +/** 범위 칸 꼬리 — 한 · 영 */ +const LOW_HIGH = currentLanguageIndex === 1 ? ["low", "high"] : ["아래", "위"]; + +export const RULES = ["초과·이하", "이상·이하", "이상·미만", "이하"]; + +/** 표 칸 놓기(서버 `M01_SL_Router._table` 과 같음 · 정함) — 조건 칸 → 값 칸 차례 · 범위(조건 「범위」 · 값 칸에 + * 「20~45」 꼴이 한 줄이라도 있음)는 「이름·아래」 「이름·위」 두 칸 · 범위 칸에 숫자 하나면 두 칸에 같은 수 */ +export function tableCols( + table: SlImportTable, +): { key: string; head: string; part: "v" | "lo" | "hi" }[] { + const out: { key: string; head: string; part: "v" | "lo" | "hi" }[] = []; + const conds = Object.keys(table.조건); + for (const k of [...conds, ...Object.keys(table.값칸)]) { + const split = conds.includes(k) + ? table.조건[k] === "범위" + : table.줄.some((r) => Array.isArray(r[k])); + if (split) { + out.push({ key: k, head: `${k}·${LOW_HIGH[0]}`, part: "lo" }); + out.push({ key: k, head: `${k}·${LOW_HIGH[1]}`, part: "hi" }); + } else out.push({ key: k, head: k, part: "v" }); + } + return out; +} + +/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 「표」 답(`rows`)과 같음 */ +export function tableRows(table: SlImportTable): (string | number | null)[][] { + const cols = tableCols(table); + const one = (v: unknown, part: "v" | "lo" | "hi"): string | number | null => { + if (part !== "v") { + const x = Array.isArray(v) ? v[part === "lo" ? 0 : 1] : v; + return typeof x === "number" ? x : null; + } + if (Array.isArray(v)) return v.map((x) => x ?? "").join(","); + return typeof v === "number" || (typeof v === "string" && v !== "") ? v : null; + }; + return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))]; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index 746509c34..a25652913 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -5,6 +5,10 @@ * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 * * 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만 + * + * 약속 ① 표 블록 = 서버(SL_Router `_table`) 칸 차례 하나 — 머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 조건이든 값이든 + * 「이름·아래」 「이름·위」 두 칸(숫자 하나면 둘 다 그 수) · 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸 + * 약속 ② 항목 상자 단가 = Item 의 칸별 목록(`itemOutsides`)이 바깥 값 주인 · 서버는 저장 때 그 목록도 받고 같은 칸이면 그쪽이 이김 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -52,7 +56,8 @@ export interface SlEdge { export type SlField = "노무비" | "재료비" | "경비" | "계"; /** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로 - * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 열 차례 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) + * 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(pillsOut · outside) — 고정 A1 아님 · Eval 이 그 칸부터 채움 * · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */ export type SlSource = | { kind: "표"; table: string } @@ -107,7 +112,6 @@ const TEXT = { Fixed: ["늘 이 값", "Always this value"], Cond: ["조건", "Condition"], Found: ["결과", "Result"], - FromTable: ["표에서 찾은 값", "Value found in the table"], ValueA: ["값1", "Value 1"], ValueB: ["값2", "Value 2"], Times: ["값1 × 값2", "Value 1 × Value 2"], @@ -118,6 +122,13 @@ const TEXT = { PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"], LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"], Sum: ["계", "Total"], + PriceLabor: ["노무 단가", "Labor price"], + PriceMat: ["재료 단가", "Material price"], + PriceExp: ["경비 단가", "Expense price"], + Labor3: ["노무비", "Labor cost"], + Mat3: ["재료비", "Material cost"], + Exp3: ["경비", "Expense"], + Won: ["원", "KRW"], NoPill: ["알약이 없음", "No such pill"], Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], @@ -136,6 +147,9 @@ const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex == /** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */ const COSTS = ["노무비", "재료비", "경비"] as const; +/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */ +const USED = "사용 수량"; + /* ── 작은 도움 ──────────────────────────────────────────────────────────── */ const STAGE: Record = { @@ -206,14 +220,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out case "const": return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] }; case "table": - return { - rows: [ - [tl("Cond"), null, tl("QtyNote")], - [tl("Found"), null, tl("FromTable")], - ], - ins: [{ name: tl("Cond"), cell: "B2" }], - outs: [{ name: tl("Found"), cell: "B3" }], - }; + // 빈 시트 — 표를 고르면 Table(sub3)이 A1 블록 · 찾기 판 · 알약을 채움(머리 줄이 표 블록과 겹치지 않게) + return { rows: [], ins: [], outs: [] }; case "calc": return { rows: [ @@ -228,24 +236,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out outs: [{ name: tl("Found"), cell: "B4" }], }; case "item": - return { - rows: [ - [tl("Qty"), null, tl("QtyNote")], - [tl("Price"), null, tl("PriceNote")], - [tl("Amount"), f("B2*B3"), ""], - ["노무비", f("B4"), ""], - ["재료비", v(0), ""], - ["경비", v(0), ""], - ], - ins: [ - { name: tl("Qty"), cell: "B2" }, - { name: tl("Price"), cell: "B3", outside: true }, - ], - outs: [ - { name: tl("Amount"), cell: "B4" }, - ...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })), - ], - }; + // 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`) + return { rows: [], ins: [], outs: [] }; case "logic": return { rows: [[tl("Found"), null, tl("LogicNote")]], @@ -266,6 +258,52 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out } } +/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움) + * B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */ +function itemSheet(): { cells: Record; rows: Row[]; ins: SlPill[]; outs: SlPill[] } { + const won = tl("Won"); + const labels: [string, string][] = [ + ["A1", tl("Qty")], + ["A2", tl("PriceLabor")], + ["A3", tl("PriceMat")], + ["A4", tl("PriceExp")], + ["A5", tl("Price")], + ["A6", tl("Amount")], + ["A7", tl("Labor3")], + ["A8", tl("Mat3")], + ["A9", tl("Exp3")], + ["A10", USED], + ]; + const cells: Record = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }])); + Object.assign(cells, { + B3: { 값: 0 }, + B4: { 값: 0 }, + B5: { 식: "B2+B3+B4" }, + B6: { 식: "ROUND(B1*B5,1)" }, + B7: { 식: "ROUND(B1*B2,1)" }, + B8: { 식: "ROUND(B1*B3,1)" }, + B9: { 식: "ROUND(B1*B4,1)" }, + B10: { 식: "B1" }, + }); + const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit }); + return { + cells, + rows: [], + ins: [ + { name: tl("Qty"), cell: "B1", unit: "" }, + { name: tl("PriceLabor"), cell: "B2", unit: won, outside: true }, + ], + outs: [ + out(tl("Price"), "B5"), + out(tl("Amount"), "B6"), + out(tl("Labor3"), "B7"), + out(tl("Mat3"), "B8"), + out(tl("Exp3"), "B9"), + out(USED, "B10", ""), + ], + }; +} + /* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ /** 빈 시트 로직 */ @@ -293,12 +331,13 @@ export function addNode( book.시트.map((s) => s.id), ); const title = uniqueName(book, name); - const t = template(kind, title); - const 칸: Record = { - A1: { 값: tl("Name") }, - B1: { 값: tl("Value") }, - C1: { 값: tl("Note") }, - }; + const item = kind === "item" ? itemSheet() : null; + const t = item ?? template(kind, title); + const 칸: Record = + item?.cells ?? + (kind === "table" + ? {} + : { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") } }); t.rows.forEach(([label, cell, note], i) => { 칸[`A${i + 2}`] = { 값: label }; if (cell) 칸[`B${i + 2}`] = cell; @@ -316,6 +355,7 @@ export function addNode( sheetId, pillsIn: t.ins, pillsOut: t.outs, + ...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}), stage: STAGE[kind], }; out.nodes.push(node); 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..97a341af4 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table.ts @@ -0,0 +1,616 @@ +/* ============================================================================= + * 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: [ + ...new Set( + node.pillsOut.filter((p) => !p.outside).map((p) => p.name.replace(/·(아래|위)$/, "")), + ), + ], +}); + +/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */ +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..977fe606a --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -0,0 +1,451 @@ +/* ============================================================================= + * 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 const isSplit = (info: TblInfo, c: string): boolean => + info.conds[c] === "범위" || (c in info.vals && info.rows.some((r) => Array.isArray(r[c]))); + +/** 블록 열 차례 — 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 둘로(서버 `_table` 과 같은 차례) */ +export function layoutOf(info: TblInfo): TblCol[] { + const out: TblCol[] = []; + for (const c of [...Object.keys(info.conds), ...Object.keys(info.vals)]) + if (isSplit(info, c)) + out.push( + { header: `${c}·아래`, of: c, part: "아래" }, + { header: `${c}·위`, of: c, part: "위" }, + ); + else 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]) : v; + 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)) { + for (const h of isSplit(info, c) ? [`${c}·아래`, `${c}·위`] : [c]) + outCells[h] = idxRow( + h, + `IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(h))},${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.replace(/·(아래|위)$/, "")] || 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 !== "값") { + if (prev.conds[nm.of]) conds[nm.of] = "범위"; + else vals[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_import.py b/resources/tester/test_m01_sl_import.py new file mode 100644 index 000000000..750efad84 --- /dev/null +++ b/resources/tester/test_m01_sl_import.py @@ -0,0 +1,299 @@ +"""M01 시트 로직 가져오기 시험(35-5 · 정함 9) — Node 로 `M01_SL_Import.ts` 를 바로 돌림. + +· 견본 로직 줄 여섯(정함 9 시험 로직)은 이 시험 안에 둠 — 품셈 표 · 노임 · 자재 · 기존 로직 값은 마스터에서 읽기만 +· 가져온 시트 로직 → 바깥 값 넣기(파이썬 엔진이 마스터에서) → 우리 스프레드시트 엔진(Node 번들)으로 셈 + → 합계 · 비목 = 지금 `/calc`(`M01_LB_Calc.calc` = `/calc` 와 같은 길) · 끝수 차이는 원 미만 +· 마스터 로직 1,469 개(자체 빼고) 모두 가져와 다 바뀌는 수를 세고 · 바뀐 통합문서를 엔진이 다 풂(#NAME? 없음) +· 파일 안 씀 — 마스터는 읽기만 · 넘김은 표준입력 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.ts" +PARSE = IMPORT.with_name("M01_SL_Import_Parse.ts") + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ──────────── +HOOKS = r""" +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.ts)?$/; +export async function resolve(spec, ctx, next) { + if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { + const u = new URL(spec + ".ts", ctx.parentURL); + if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true }; + } + return next(spec, ctx); +} +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\s+"[^"]+\.css";/g, ""); + src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => { + if (from.startsWith(".") && ALLOW.test(from)) return m; + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" +STUB = ( + "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : " + "() => undefined });\n" +) + + +def _plain(x: object) -> object: + return float(x) if isinstance(x, Decimal) else str(x) + + +def _node(body: str, payload: object) -> object: + """`I` = Import 모듈 · `A` = 넘긴 것(표준입력) · `out(x)` 로 답""" + script = ( + "import { register } from 'node:module';\n" + "import { readFileSync } from 'node:fs';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + + STUB + + f"const I = {{ ...(await import({json.dumps(IMPORT.as_uri())}))," + f" ...(await import({json.dumps(PARSE.as_uri())})) }};\n" + "const A = JSON.parse(readFileSync(0, 'utf8'));\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + input=json.dumps(payload, ensure_ascii=False, default=_plain), + capture_output=True, text=True, encoding="utf-8", timeout=600, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-3000:] + return json.loads(result.stdout) + + +# ── 견본 줄 여섯(정함 9) + 넣을 값 ──────────────────────────────────────────── +H = {"원문번호": "", "출처": "자체", "소유": "공용", "끝수": None} +SAMPLES = [ + ( + {**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [], + "호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"}, + "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}], + "덧줄": []}, + {}, + ), + ( + {**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡", + "입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75]}, + {"이름": "돌", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]}, + {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, + {"이름": "높이", "단위": "m"}, {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}], + "중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}], + "호표": [{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "비목": "노무비", + "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"}, + {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비", + "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"}], + "덧줄": []}, + {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "높이": 4.5, "초과증가율": 80}, + ), + ( + {**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡", + "입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}], + "중간": [], + "호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비", + "수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)"}], + "덧줄": []}, + {"품": 2.5, "비탈구배": "평균 비탈구배 20˚~45˚ 이하"}, + ), + ( + {**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥", + "입력": [{"이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기"]}], + "중간": [{"이름": "작업량", "식": "로직(GF000114)"}], + "호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton", + "단위": "시간", "수량": "1 / 작업량"}], + "덧줄": []}, + {"지역": "서울"}, + ), + ( + {**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간", + "입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f"}, {"이름": "E"}, {"이름": "Cm", "단위": "분"}], + "중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)"}}, + {"qt": 6, "f": 1.2, "E": 0.9, "Cm": 25}, + ), + ( + {**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [], + "중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}], + "호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}], + "덧줄": [{"이름": "공구손료 및 경장비", "비목": "경비", + "식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100"}]}, + {}, + ), +] # fmt: skip + + +# ── 마스터(읽기만) ───────────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def master(): + from M01_MasterData import M01_MasterData_Store as store + + cm = store.cm + files = cm.load(folder=store.FOLDER) + tables = { + str(t["키"]): t + for data in files.values() + if data.get("그룹") in cm.mf.TABLE_GROUPS + for t in data.get("표") or [] + } + return cm, files, tables + + +def _tables_of(row: dict, tables: dict) -> dict: + keys = set(re.findall(r"Q[A-Z]\d{6}", json.dumps(row, ensure_ascii=False))) + return {k: tables[k] for k in keys if k in tables} + + +def _num(v) -> float: + return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan") + + +def _fill(sl: dict, places: list, py_lines: list, master) -> None: + """바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`""" + cm, files, _ = master + m = cm.mf.Master(files) + nodes = {n["id"]: n for n in sl["nodes"]} + for at in places: + item = at["item"] + kind = item["kind"] + if kind == "표": + continue + if kind == "노임": + value = cm.mf.element(m, item["key"], {})[0] + elif kind == "자재": + name = nodes[item["id"].split(":")[0]]["name"] + value = next(x["단가"] for x in py_lines if x["이름"] == name) + else: + key = ( + item["key"] + if kind == "로직" + else re.match(r"로직\((\w+)", item["element"]).group(1) + ) + got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v + for k, v in item["inputs"].items()}) # fmt: skip + value = got["결과"] if "결과" in got else got[item.get("field") or "계"] + if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0) + continue + sheet = next(s for s in sl["workbook"]["시트"] if s["id"] == at["sheet"]) + sheet["칸"][at["cell"]] = {"값": float(value)} + + +def _result_cells(sl: dict) -> dict: + node = next(n for n in sl["nodes"] if n["kind"] == "result") + return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]} + + +@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES]) +def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, master) -> None: + from common_util.common_util_spreadsheet import recalc_workbooks + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + rep = _node( + "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });" + "out({ ...r, places: I.outsideOf(r.sl) });", + {"row": row, "tables": _tables_of(row, master[2]), "given": given}, + ) + assert rep["problems"] == [] + sl = rep["sl"] + # 모양 — 상자 = 시트 · 선은 있는 상자 · 알약끼리 + sheets = {s["id"] for s in sl["workbook"]["시트"]} + assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"]) + ids = {n["id"]: n for n in sl["nodes"]} + for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴) + if n["kind"] == "table": + assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"] + for e in sl["edges"]: + assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"]) + assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"]) + + want = lb.calc(row, given, {}) + assert want["ok"], want + # 1차 셈 → 넘길 값(장비 · 로직 인자) 읽기 → 바깥 값 넣기 → 2차 셈 + first = recalc_workbooks([sl["workbook"]])[0] + places = _node("out(I.outsideOf(A.sl, A.calc));", {"sl": sl, "calc": first}) + _fill(sl, places, want.get("lines", []), master) + got = recalc_workbooks([sl["workbook"]])[0] + cells = _result_cells(sl) + read = {k: _num(got[s].get(c)) for k, (s, c) in cells.items()} + print(f"\n{row['키']} 시트 {read} · /calc {want.get('result', want.get('sums'))}") + if "result" in want: + assert abs(read["결과"] - float(want["result"])) < 1e-9, (read, want["result"]) + return + for k in ("노무비", "재료비", "경비", "계"): + diff = abs(read[k] - float(want["sums"][k])) + assert diff < 1, f"{row['키']} {k}: 시트 {read[k]} · /calc {want['sums'][k]}" + + +def test_마스터_1469_가져오기_세기(master) -> None: + from common_util.common_util_spreadsheet import recalc_workbooks + + cm, files, tables = master + rows = [ + r + for name, data in files.items() + if data.get("그룹") == "로직" and name != "로직_자체.json" + for r in data.get("줄") or [] + ] + assert len(rows) == 1469 + got = _node( + "const res = A.rows.map((row) => { try { const r = I.importLogicReport(row, { tables: A.tables });" + "return { key: row.키, problems: r.problems, book: r.problems.length ? null : r.sl.workbook }; }" + "catch (e) { return { key: row.키, problems: ['던짐 ' + e.message], book: null }; } });" + "out(res);", + {"rows": rows, "tables": tables}, + ) + thrown = [g["key"] for g in got if any(p.startswith("던짐") for p in g["problems"])] + assert thrown == [] + full = [g for g in got if not g["problems"]] + # 다 바뀐 것은 엔진이 다 풂 — 모르는 함수 · 이름(#NAME?) 없음 + values = recalc_workbooks([g["book"] for g in full]) + named = [ + g["key"] for g, v in zip(full, values) if "#NAME?" in json.dumps(v, ensure_ascii=False) + ] + assert named == [] + reasons: dict[str, int] = {} + for g in got: + for p in g["problems"]: + why = re.sub(r"「[^」]*」", "「…」", p.split(" — ", 1)[-1]) + reasons[why] = reasons.get(why, 0) + 1 + print( + f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}" + ) + assert len(full) > 1000 + + +def test_표_블록_칸_차례_정함() -> None: + """조건 칸 → 값 칸 · 범위(조건 「범위」 · 값 칸에 범위가 한 줄이라도)는 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸에 같은 수""" + table = { + "조건": {"높이": "범위", "구분": "고르기"}, + "값칸": {"증가율": "%", "단위": "글"}, + "줄": [ + {"높이": [3, 4], "구분": "가", "증가율": 30, "단위": "%"}, + {"높이": 5, "구분": "나", "증가율": [80, 100]}, + {"높이": [7.5, None], "증가율": 50}, + ], + } + rows = _node("out(I.tableRows(A));", table) + assert rows == [ + ["높이·아래", "높이·위", "구분", "증가율·아래", "증가율·위", "단위"], + [3, 4, "가", 30, 30, "%"], + [5, 5, "나", 80, 100, None], + [7.5, None, None, 50, 50, None], + ] diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index 204c9fcb9..81b68cf4c 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -71,7 +71,7 @@ let sl = M.newLogic("시험 하나", "원/㎡"); let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002"; r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; -sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]); +sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]); sl = link(sl, pum, "품", man, "수량"); sl = link(sl, man, "노무비", res, "노무비"); """ @@ -87,7 +87,7 @@ r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node; r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; sl = E.applyOutside(sl, [ { node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 }, - { node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 }, + { node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 }, ]); sl = link(sl, pum, "품", calc, "값1"); sl = link(sl, rate, "할증률", calc, "값2"); @@ -101,12 +101,12 @@ sl = link(sl, man, "노무비", res, "노무비"); def test_입력값_항목_결과_수식과_값() -> None: got = _run( ONE - + "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," + + "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," " sl.workbook.시트.map(s => s.이름)]);" ) qty, total_in, ev, edges, kinds, names = got assert qty == {"식": "품!B2"} - assert total_in == {"식": "'보통 인부'!B5"} + assert total_in == {"식": "'보통 인부'!B7"} assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000} assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5 assert ev["errors"] == [] @@ -132,7 +132,7 @@ def test_계산_두_항목_더함_값바뀜() -> None: # 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000 assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000} assert c == {"결과": 0.26} - assert cell == {"식": "석공!B5+보통인부!B5"} + assert cell == {"식": "석공!B7+보통인부!B7"} assert b["계"] == 0.2 * 1.5 * 200000 + 20000 @@ -146,7 +146,7 @@ def test_선_끊기_다시_잇기() -> None: " E.evalLogic(c).result, a.edges.length]);" ) cell_a, ra, cell_b, check_b, rc, n_edges = got - assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000 + assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000 assert cell_b is None assert "결과 상자에 이어진 선이 없음" in check_b assert not any("입력 알약" in x for x in check_b) @@ -202,7 +202,7 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ) cells, e1, f2, e3, f3, lg, check = got assert cells == [ - {"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, + {"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"}, { "id": "n4!E1", "node": "n4", @@ -222,7 +222,7 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ] assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None) assert lg == {"결과": 12.5} - assert "「메쌓기 표」의 입력 알약 「조건」이 비어 있음" in check + assert check == [] def test_알약_정하기_이름바꾸면_선이_따라감() -> None: @@ -230,16 +230,16 @@ def test_알약_정하기_이름바꾸면_선이_따라감() -> None: ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n" "const e = s2.edges[0].from;\n" "s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n" - "const moved = sheet(s2, man.id).칸.B2;\n" + "const moved = sheet(s2, man.id).칸.B1;\n" "const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n" "const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n" - "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" + "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" ) e, moved, n_edges, cleared, ins = got assert e == {"node": "n1", "pill": "품셈 품"} assert moved == {"식": "품!D2"} assert n_edges == 1 and cleared is None - assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]] + assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]] def test_상자_지우기() -> None: @@ -250,7 +250,7 @@ def test_상자_지우기() -> None: ) names, sheets, n_edges, cell, total, before = got assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names - assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000 + assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000 assert before == 6 # 받은 것은 안 바뀜 @@ -288,11 +288,11 @@ def test_상자_이름_바꾸기_수식이_따라감() -> None: ) why, name, sheet_name, cell, total, tries, before = got assert why is None and name == sheet_name == "석공 (돌쌓기)" - assert cell == {"식": "'석공 (돌쌓기)'!B5+보통인부!B5"} and total == 72000 + assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000 assert tries == [ "같은 이름 상자가 있음", "이름은 1~31 글자", - "이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '", + "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", "상자가 없음", "이름은 1~31 글자", ] @@ -316,7 +316,7 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None: "const cells = E.outsideCells(sl);\n" "const head = M.rangeHead('높이');\n" "const got = E.answersToValues(cells, [\n" - " { id: 'n2!B3', ok: true, value: 172068 },\n" + " { id: 'n2!B2', ok: true, value: 172068 },\n" " { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n" "]);\n" "const s2 = E.applyOutside(sl, got.values);\n" @@ -325,10 +325,89 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None: head, got_values, f1, g2, total = got assert head == ["높이·아래", "높이·위"] assert got_values["errors"] == [] - assert got_values["values"][0] == {"node": "n2", "cell": "B3", "value": 172068} + assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068} assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034) missing = _run( ONE - + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));" + + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));" ) - assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]} + assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]} + + +def test_항목_틀은_Item_노무_틀과_같은_칸() -> None: + got = _run( + "let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');" + " out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);" + ) + cells, ins, outs, ref = got + assert {k: v for k, v in cells.items() if k.startswith("B")} == { + "B3": {"값": 0}, + "B4": {"값": 0}, + "B5": {"식": "B2+B3+B4"}, + "B6": {"식": "ROUND(B1*B5,1)"}, + "B7": {"식": "ROUND(B1*B2,1)"}, + "B8": {"식": "ROUND(B1*B3,1)"}, + "B9": {"식": "ROUND(B1*B4,1)"}, + "B10": {"식": "B1"}, + } + assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [ + "수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량", + ] # fmt: skip + assert ins == [ + {"name": "수량", "cell": "B1", "unit": ""}, + {"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True}, + ] + assert outs == [ + ["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"], + ] # fmt: skip + assert ref == '{"what":"노무"}' + + +# 표 상자 이음매 — Table_Build(sub3) 가 짠 품셈 표 상자에 서버 꼴 블록(머리 줄 + 줄 · A1 부터)을 넣고 찾기 +TABLE = r""" +const T = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Table_Build.ts")); +let sl = M.newLogic("표 시험"); +let r = M.addNode(sl, "table", "메쌓기"); sl = r.sl; const tb = r.node; +const info = { key: "QF000421", name: "메쌓기", base: "1㎡", source: "", no: "13-4-1", + conds: { 뒷길이: "수", 돌: "고르기" }, vals: { 석공: "인" }, rule: null, notes: [], + rows: [{ 뒷길이: 25, 돌: "깬잡석", 석공: 0.15 }, { 뒷길이: 30, 돌: "깬돌", 석공: 0.26 }, + { 뒷길이: 35, 돌: "깬돌", 석공: 0.3 }] }; +sl = T.applyTable(sl, tb.id, info, { cond: ["뒷길이", "돌"], out: ["석공"] }, + { ref: "QF000421", fillBlock: false, outside: true }).sl; +r = M.addNode(sl, "input", "뒷길이"); sl = r.sl; const len = r.node; +r = M.addNode(sl, "input", "돌"); sl = r.sl; const stone = r.node; +sl = E.applyOutside(sl, [{ node: len.id, cell: "B2", value: 35 }, { node: stone.id, cell: "B2", value: "깬돌" }]); +sl = link(sl, len, "뒷길이", sl.nodes.find(n => n.id === tb.id), "뒷길이"); +sl = link(sl, stone, "돌", sl.nodes.find(n => n.id === tb.id), "돌"); +const ROWS = [["뒷길이", "돌", "석공"], [25, "깬잡석", 0.15], [30, "깬돌", 0.26], [35, "깬돌", 0.3]]; +const cells = E.outsideCells(sl); +""" + + +def test_표_상자_바깥값_블록_찾기_줄면_옛줄_지움_머리바뀜_알림() -> None: + got = _run( + TABLE + + "const first = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS }], sl);\n" + "const s1 = E.applyOutside(sl, first.values);\n" + "const shrink = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS.slice(0, 3) }], s1);\n" + "const s2 = E.applyOutside(s1, shrink.values);\n" + "const moved = E.answersToValues(cells, [{ id: cells[0].id, ok: true," + " rows: [['돌', '뒷길이', '석공'], ['깬돌', 30, 0.26]] }], s1);\n" + "out([cells, first.errors, E.evalLogic(s1).pills[tb.id], shrink.errors, sheet(s2, tb.id).칸.A4 ?? null," + " sheet(s2, tb.id).칸.C4 ?? null, E.evalLogic(s2).pills[tb.id], moved.errors]);" + ) + cells, first_err, found, shrink_err, a4, c4, gone, moved_err = got + assert cells == [ + { + "id": "n1!A1", + "node": "n1", + "cell": "A1", + "kind": "table", + "ref": "QF000421", + "source": {"kind": "표", "table": "QF000421"}, + } + ] + assert first_err == [] and found["석공"] == 0.3 and found["표"] == "뒷길이" + assert shrink_err == [] and a4 is None and c4 is None + assert gone["석공"] == "#N/A" + assert moved_err == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"] 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..a1f6eb1df --- /dev/null +++ b/resources/tester/test_m01_sl_table_build.py @@ -0,0 +1,232 @@ +"""시트 로직 표 상자 — 찾기 수식 짜기(`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" + + +def test_값_칸_범위도_아래_위_두_칸_숫자_하나는_같은_수(): + """서버 표 블록 차례 — 범위 값은 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸이 같은 수.""" + table = { + **R, + "conds": {"높이": "범위"}, + "vals": {"증가율": "%"}, + "rule": "초과·이하", + "rows": [{"높이": [3, 4], "증가율": [80, 100]}, {"높이": [4, None], "증가율": 30}], + } + low = solve(table, {"높이": 3.5}, ["증가율"]) + assert float(low["증가율·아래"]) == 80 and float(low["증가율·위"]) == 100 + one = solve(table, {"높이": 5}, ["증가율"]) + assert float(one["증가율·아래"]) == 30 and float(one["증가율·위"]) == 30 + + +def test_블록_머리_차례(): + table = { + **R, + "conds": {"가": "고르기", "높이": "범위"}, + "vals": {"율": "%", "폭": "m"}, + "rows": [{"가": "a", "높이": [1, 2], "율": 1, "폭": [20, 45]}], + } + plan = run([{"op": "plan", "info": table, "roles": {"cond": ["가"], "out": ["율"]}}])[0]["plan"] + assert [c["header"] for c in plan["cols"]] == [ + "가", + "높이·아래", + "높이·위", + "율", + "폭·아래", + "폭·위", + ]