diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index d452f6b95..54cd49c06 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -76,7 +76,7 @@ export interface SpreadsheetHandle { setDoc(book: Workbook): void; /** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */ recalc(): CalcValues; - /** 알약 키 → 연결 칸 값(빈 칸 0) */ + /** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */ getLinkedValues(): Record; /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · * 주소가 틀리거나 읽기만이면 false */ @@ -289,7 +289,9 @@ export function createSpreadsheet( for (const s of ctx.book.시트) for (const l of s.연결 ?? []) { const p = parseA1(l.칸); - if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); + const cell = s.칸[l.칸]; + const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === ""); + if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); } return out; }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 3f1df113f..6ba71c3c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -15,6 +15,7 @@ const TEXT = { close: "닫기", now: "지금 연결", none: "연결 없음", + empty: "빈 칸", picked: "지금 고른 칸으로 ({value})", address: "칸 주소", hint: "예 B6", @@ -51,6 +52,7 @@ function linkedText(opts: AreaLinkOptions): string | null { .find((l) => l.키 === opts.key); if (!link) return null; const value = opts.sheet.getLinkedValues()[opts.key]; + if (!value) return `${link.칸} = ${TEXT.empty}`; return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 39c55433d..37c68493a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -47,13 +47,27 @@ export interface AreaPanel { destroy: () => void; } -/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */ +/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */ +function toNumber(raw: unknown): number | null { + if (raw === null || raw === undefined || typeof raw === "boolean") return null; + const text = String(raw).replace(/,/g, "").trim(); + const n = Number(text); + return text !== "" && Number.isFinite(n) ? n : null; +} + +/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 · + * 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */ function quantities(sheet: SpreadsheetHandle | null): Record { const out: Record = {}; - for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) { - const n = "수" in v ? Number(v.수) : NaN; - if (Number.isFinite(n)) out[key] = n; - } + if (!sheet) return out; + const values = sheet.getLinkedValues(); + for (const s of sheet.getDoc().시트) + for (const l of s.연결 ?? []) { + const cell = s.칸[l.칸]; + const v = values[l.키]; + const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값); + if (n !== null) out[l.키] = n; + } return out; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 9e3bc3fbe..a3c3e8484 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -33,13 +33,18 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */ +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · + * 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */ export function qtyText( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, cells: ReadonlyMap = new Map(), ): string { - if (!line || line.수량 === null || line.수량 === undefined) return ""; + if (!line) return ""; + if (line.수량 === null || line.수량 === undefined) { + const at = line.알약 ? cells.get(line.알약) : undefined; + return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : ""; + } const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index cba1e88bb..31ecc6c84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -75,6 +75,7 @@ const TEXT = { In_Memo: ["메모", "Memo"], In_Qty: ["수량", "Quantity"], In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyEmpty: ["빈 칸", "empty cell"], In_QtyHand: ["손 입력", "Manual"], In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index 2b8e56228..fb81c0d7a 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -47,3 +47,44 @@ def test_칸_지정_모달은_setLink_만(): assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link assert "saveTemplate" not in link # 저장은 [저장] + + +def test_빈_칸은_수량_없음_서버와_같음(): + """39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + doc = { + "시트": [ + { + "id": "s1", + "칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}}, + "연결": [ + {"키": k, "칸": c} + for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4")) + ], + } + ] + } + assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250} + panel = _text("M02_MasterTemplete_AreaPanel.ts") + body = panel.split("function quantities")[1].split("\n}\n")[0] + assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body + assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel + sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet + rows = _text("M02_MasterTemplete_Labor_Rows.ts") + assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows + assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text( + "M02_MasterTemplete_AreaLink.ts" + ) + + +def test_카드_알약_취향은_계정_받은_뒤_올림(): + """39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음.""" + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0] + assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push + assert "prefsPull = pullUiPrefs();" in state + cards = _text("M02_MasterTemplete_ItemCards.ts") + load = cards.split("async function load(")[1] + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 2d96b1d94..5915821cb 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -131,6 +131,7 @@ console.log(JSON.stringify([ m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"), m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells), m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"), ])); """ @@ -148,5 +149,6 @@ def test_수량_글은_이은_칸_주소를_보임() -> None: "수량 B6 = 20 ㎡", "수량 면적산출 칸 = 20 ㎡", "수량 손 입력 3 ㎡", + "수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음) "", ]