From 46c7f8e41cb19e4a60fd959fbfa85c2ffa416045 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:29:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(m01-sl):=20=EA=B0=80=EC=A0=B8=EC=98=A8=20?= =?UTF-8?q?=ED=91=9C=20=EB=B8=94=EB=A1=9D=20=EC=B9=B8=20=EC=B0=A8=EB=A1=80?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=95=ED=95=A8=EB=8C=80=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=92=20=EC=B9=B8=20=EB=B2=94=EC=9C=84=EB=8F=84=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EC=9C=84=20=EB=91=90=20=EC=B9=B8=20=C2=B7?= =?UTF-8?q?=20=EC=88=AB=EC=9E=90=20=ED=95=98=EB=82=98=EB=A9=B4=20=EB=91=90?= =?UTF-8?q?=20=EC=B9=B8=EC=97=90=20=EA=B0=99=EC=9D=80=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/SheetLogic/M01_SL_Import.ts | 22 ++++++++++------- .../SheetLogic/M01_SL_Import_Parse.ts | 23 ++++++++++-------- resources/tester/test_m01_sl_import.py | 24 ++++++++++++++++++- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts index 93db36b6c..1b5639b87 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -98,8 +98,8 @@ const cellOf = (v: unknown): Cell | null => interface TableBox { node: SlNode; table: SlImportTable; - /** 결과 칸 이름 → 표 열 글자 */ - colOf: Map; + /** 결과 칸 이름 → 표 열 글자(범위 칸은 아래 · 위) */ + colOf: Map; rows: number; /** 다음 빈 열(맞음 · 결과 셈 칸) */ free: number; @@ -442,28 +442,32 @@ function newTable(b: Build, n: Extract, sig: string): TableB }; } } - const colOf = new Map(); - for (const c of cols) if (c.part === "v") colOf.set(c.key, colName(4 + cols.indexOf(c))); + 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 */ +/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 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 = box.colOf.get(col)!; + 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)`, }; - b.out(box.node, col, { - 식: `IF(COUNTIF(${range(W)},1)=1,INDEX(${range(X)},MATCH(1,${range(W)},0)),NA())`, - }); + 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())` }); } /* ── 상자들 ─────────────────────────────────────────────────────────────── */ diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts index 851582d78..0bdcffee6 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts @@ -152,32 +152,35 @@ 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" }[] = []; - for (const [k, kind] of Object.entries(table.조건)) { - if (kind === "범위") { + 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" }); } - for (const k of Object.keys(table.값칸)) - if (!(k in table.조건)) out.push({ key: k, head: k, part: "v" }); return out; } -/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 모양(`M01_SL_Api` 「표」)과 같음 */ +/** 품셈 표 → 시트 칸(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 (Array.isArray(v)) { - if (part === "v") return v.map((x) => x ?? "").join("~"); - const x = v[part === "lo" ? 0 : 1]; + if (part !== "v") { + const x = Array.isArray(v) ? v[part === "lo" ? 0 : 1] : v; return typeof x === "number" ? x : null; } - return typeof v === "number" || typeof v === "string" ? v : 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/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py index 3187d11a3..750efad84 100644 --- a/resources/tester/test_m01_sl_import.py +++ b/resources/tester/test_m01_sl_import.py @@ -20,6 +20,7 @@ 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 가 없음") @@ -66,7 +67,8 @@ def _node(body: str, payload: object) -> object: "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())});\n" + + 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 ) @@ -275,3 +277,23 @@ def test_마스터_1469_가져오기_세기(master) -> None: 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], + ]