feat(m01-sl): 표 상자 이음매 — 블록은 표 알약 칸부터 · 줄면 옛 줄 지움 · 머리 줄 바뀜 알림

- 표 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(A1 · E1 무엇이든) · applyOutside 가 그 칸부터 머리 줄 포함 채움
- 표가 줄었으면 블록 열 아래 남은 옛 값 칸을 지움(찾기 판 수식은 블록 열 밖이라 그대로)
- answersToValues(cells, 답, sl) — 시트 머리 줄과 받은 머리 줄이 다르면 쉬운 말
- addNode(table) = 빈 시트(Table 이 블록 · 찾기 판 · 알약을 채움 — 틀 칸이 블록과 겹치지 않게)
- 시험 15개(Table_Build 와 잇는 시험 더함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-01 13:17:13 +09:00
co-authored by Claude Opus 5.5
parent 8e2d8d6bb3
commit ebf51d8b35
3 changed files with 93 additions and 16 deletions
+33
View File
@@ -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<string, Cell>, 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;
}
+9 -15
View File
@@ -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<string, Cell> = item?.cells ?? {
A1: { 값: tl("Name") },
B1: { 값: tl("Value") },
C1: { 값: tl("Note") },
};
const 칸: Record<string, Cell> =
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;