Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -98,8 +98,8 @@ const cellOf = (v: unknown): Cell | null =>
|
||||
interface TableBox {
|
||||
node: SlNode;
|
||||
table: SlImportTable;
|
||||
/** 결과 칸 이름 → 표 열 글자 */
|
||||
colOf: Map<string, string>;
|
||||
/** 결과 칸 이름 → 표 열 글자(범위 칸은 아래 · 위) */
|
||||
colOf: Map<string, { x: string; hi?: string }>;
|
||||
rows: number;
|
||||
/** 다음 빈 열(맞음 · 결과 셈 칸) */
|
||||
free: number;
|
||||
@@ -442,28 +442,32 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
|
||||
};
|
||||
}
|
||||
}
|
||||
const colOf = new Map<string, string>();
|
||||
for (const c of cols) if (c.part === "v") colOf.set(c.key, colName(4 + cols.indexOf(c)));
|
||||
const colOf = new Map<string, { x: string; hi?: string }>();
|
||||
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())` });
|
||||
}
|
||||
|
||||
/* ── 상자들 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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)))];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -165,7 +165,11 @@ async function loadInfo(key: string): Promise<TblInfo> {
|
||||
/* ── 역할 · 알약 ── */
|
||||
const rolesOf = (node: SlNode): TblRoles => ({
|
||||
cond: node.pillsIn.map((p) => p.name),
|
||||
out: node.pillsOut.filter((p) => !p.outside).map((p) => p.name),
|
||||
out: [
|
||||
...new Set(
|
||||
node.pillsOut.filter((p) => !p.outside).map((p) => p.name.replace(/·(아래|위)$/, "")),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */
|
||||
|
||||
@@ -77,17 +77,20 @@ 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))
|
||||
if (info.conds[c] === "범위")
|
||||
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: "값" });
|
||||
for (const c of Object.keys(info.vals)) out.push({ header: c, of: c, part: "값" });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -106,13 +109,8 @@ export function blockCells(info: TblInfo): Record<string, Cell> {
|
||||
info.rows.forEach((row, i) =>
|
||||
cols.forEach((col, c) => {
|
||||
const v = row[col.of];
|
||||
const x = Array.isArray(v)
|
||||
? col.part === "아래"
|
||||
? v[0]
|
||||
: v[1]
|
||||
: col.part === "값"
|
||||
? v
|
||||
: null;
|
||||
// 범위 칸에 숫자 하나면 아래 · 위에 같은 수
|
||||
const x = Array.isArray(v) ? (col.part === "아래" ? v[0] : v[1]) : v;
|
||||
const cell = cellOf(x);
|
||||
if (cell) cells[at(i + 2, c)] = cell;
|
||||
}),
|
||||
@@ -258,10 +256,11 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
|
||||
);
|
||||
const outCells: Record<string, string> = {};
|
||||
for (const c of roles.out.filter((o) => o in info.vals)) {
|
||||
outCells[c] = idxRow(
|
||||
c,
|
||||
`IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(c))},${idx}),NA())`,
|
||||
);
|
||||
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,
|
||||
@@ -286,7 +285,7 @@ export function pillsOf(
|
||||
const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({
|
||||
name,
|
||||
cell,
|
||||
unit: info.vals[name] || undefined,
|
||||
unit: info.vals[name.replace(/·(아래|위)$/, "")] || undefined,
|
||||
}));
|
||||
if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true });
|
||||
return { pillsIn, pillsOut };
|
||||
@@ -416,8 +415,10 @@ export function infoFromSheet(sheet: Sheet, prev: Pick<TblInfo, "conds" | "name"
|
||||
names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c });
|
||||
});
|
||||
for (const nm of names) {
|
||||
if (nm.part !== "값") conds[nm.of] = "범위";
|
||||
else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of];
|
||||
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[] = [];
|
||||
|
||||
@@ -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],
|
||||
]
|
||||
|
||||
@@ -197,3 +197,36 @@ 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"]] == [
|
||||
"가",
|
||||
"높이·아래",
|
||||
"높이·위",
|
||||
"율",
|
||||
"폭·아래",
|
||||
"폭·위",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user