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_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index d457e5676..a9253d047 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -52,7 +52,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 +108,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"], @@ -216,14 +216,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: [ @@ -335,11 +329,11 @@ export function addNode( const title = uniqueName(book, name); const item = kind === "item" ? itemSheet() : null; const t = item ?? template(kind, title); - const 칸: Record = item?.cells ?? { - A1: { 값: tl("Name") }, - B1: { 값: tl("Value") }, - C1: { 값: tl("Note") }, - }; + 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; diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index ee944877a..81b68cf4c 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -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: @@ -361,3 +361,53 @@ def test_항목_틀은_Item_노무_틀과_같은_칸() -> None: ["단가", "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 == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"]