diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts index 0c8b3550d..69bc12453 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Eval.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -9,7 +9,13 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model"; +import { + nodeSheet, + type SheetLogic, + type SlNode, + type SlOutside, + type SlSource, +} from "./M01_SL_Model"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ export type SlValue = number | string | boolean | null; @@ -48,6 +54,11 @@ const TEXT = { "Outside value 「{pill}」 of 「{box}」 not filled yet", ], NoResult: ["결과 상자가 없음", "There is no result box"], + /** {cell} {why} */ + NoAnswer: [ + "{cell} 칸 바깥 값을 못 받음 {why}", + "Couldn't get the outside value for {cell} {why}", + ], NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"], } as const; @@ -63,13 +74,48 @@ const tl = (key: keyof typeof TEXT, fill: Record = {}): string = export function outsideCells(sl: SheetLogic): SlOutside[] { const kindOf = (n: SlNode): SlOutside["kind"] => n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price"; + const sourceOf = (n: SlNode): SlSource | undefined => + n.source ?? + (n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ?? + (n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined); return sl.nodes.flatMap((n) => [...n.pillsIn, ...n.pillsOut] .filter((p) => p.outside) - .map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })), + .map((p) => { + const source = sourceOf(n); + const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) }; + return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) }; + }), ); } +/** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */ +export interface SlOutsideAnswer { + id: string; + ok: boolean; + value?: number | null; + rows?: (string | number | null)[][]; + reason?: string; +} + +/** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */ +export function answersToValues( + cells: SlOutside[], + answers: SlOutsideAnswer[], +): { values: SlOutsideValue[]; errors: string[] } { + const values: SlOutsideValue[] = []; + const errors: string[] = []; + for (const c of cells) { + const a = answers.find((x) => x.id === c.id); + if (!a || !a.ok) { + errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" })); + continue; + } + values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); + } + return { values, errors }; +} + /** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ function putValue(칸: Record, cell: string, value: SlValue): void { const style = 칸[cell]?.서식; diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index 6d3ba6ca8..746509c34 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -9,6 +9,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands"; import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; /** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ @@ -34,6 +35,8 @@ export interface SlNode { pillsOut: SlPill[]; /** 표 키 · 요소 · 기존 로직 키 · 내 표 id */ ref?: string; + /** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */ + source?: SlSource; /** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */ stage: number; } @@ -45,14 +48,33 @@ export interface SlEdge { to: { node: string; pill: string }; } -/** 바깥 값 칸 */ +/** 돈 로직 결과에서 꺼낼 칸 */ +export type SlField = "노무비" | "재료비" | "경비" | "계"; + +/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로 + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) + * · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */ +export type SlSource = + | { kind: "표"; table: string } + | { kind: "노임" | "자재"; key: string } + | { kind: "장비"; element: string; inputs: Record; field?: SlField } + | { kind: "로직"; key: string; inputs: Record; field?: SlField }; + +/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */ export interface SlOutside { + id: string; node: string; cell: string; kind: "table" | "price" | "logic"; ref: string; + source?: SlSource; } +/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */ +export const RANGE_LOW = "·아래"; +export const RANGE_HIGH = "·위"; +export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH]; + /** 내 표 — 품셈에 없는 표(사용자가 만듦) */ export interface SlUserTable { id: string; @@ -100,6 +122,13 @@ const TEXT = { Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], Twice: ["이미 이어져 있음", "Already linked"], + NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"], + NameChar: [ + "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", + "Name can't use [ ] : * ? / \\ or a leading/trailing '", + ], + NameSame: ["같은 이름 상자가 있음", "A box with this name exists"], + NoBox: ["상자가 없음", "No such box"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; @@ -130,12 +159,25 @@ function nextId(prefix: string, ids: string[]): string { return `${prefix}${Math.max(0, ...nums) + 1}`; } -/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */ -function uniqueName(book: Workbook, name: string): string { - const base = name.trim() || "상자"; - const used = new Set(book.시트.map((s) => s.이름)); - if (!used.has(base)) return base; - for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`; +/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */ +const BAD_CHARS = /[[\]:*?/\\]/g; +const cleanName = (name: string): string => + name + .replace(BAD_CHARS, "·") + .replace(/^'+|'+$/g, "") + .trim() + .slice(0, 31) || "상자"; + +/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */ +function uniqueName(book: Workbook, name: string, self?: string): string { + const base = cleanName(name); + const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase())); + if (!used.has(base.toLowerCase())) return base; + for (let n = 2; ; n++) { + const tail = ` (${n})`; + const next = base.slice(0, 31 - tail.length) + tail; + if (!used.has(next.toLowerCase())) return next; + } } const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => { @@ -435,3 +477,22 @@ export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null { const node = nodeOf(sl, nodeId); return node ? sheetOf(sl, node) : null; } + +/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */ +export function renameNode( + sl: SheetLogic, + nodeId: string, + name: string, +): { sl: SheetLogic; why: string | null } { + const node = nodeOf(sl, nodeId); + if (!node) return { sl, why: tl("NoBox") }; + const next = name.trim(); + if (!next || next.length > 31) return { sl, why: tl("NameLen") }; + if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'")) + return { sl, why: tl("NameChar") }; + if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") }; + const out = clone(sl); + applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next }); + nodeOf(out, nodeId)!.name = next; + return { sl: out, why: null }; +} diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index 03c040d21..204c9fcb9 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -202,9 +202,23 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ) cells, e1, f2, e3, f3, lg, check = got assert cells == [ - {"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, - {"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"}, - {"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"}, + {"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, + { + "id": "n4!E1", + "node": "n4", + "cell": "E1", + "kind": "table", + "ref": "QF000421", + "source": {"kind": "표", "table": "QF000421"}, + }, + { + "id": "n5!B2", + "node": "n5", + "cell": "B2", + "kind": "logic", + "ref": "GF000114", + "source": {"kind": "로직", "key": "GF000114", "inputs": {}}, + }, ] assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None) assert lg == {"결과": 12.5} @@ -261,3 +275,60 @@ def test_화면_글에_금지_낱말_없음() -> None: for text in korean: for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): assert bad not in text, (name, bad, text) + + +def test_상자_이름_바꾸기_수식이_따라감() -> None: + got = _run( + TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n" + "const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why," + " M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why," + " M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n" + "out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름," + " sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);" + ) + 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 tries == [ + "같은 이름 상자가 있음", + "이름은 1~31 글자", + "이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '", + "상자가 없음", + "이름은 1~31 글자", + ] + assert before == "석공" + + +def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None: + got = _run( + "let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;" + " r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;" + " out(sl.workbook.시트.map(s => s.이름));" + ) + assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"] + + +def test_바깥값_답을_넣을값으로_범위머리() -> None: + got = _run( + ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n" + "sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n" + "sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n" + "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: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n" + "]);\n" + "const s2 = E.applyOutside(sl, got.values);\n" + "out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);" + ) + 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 (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034) + missing = _run( + ONE + + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));" + ) + assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]}