From c535de7b315193cdb67f65231e934065f6531acf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(M02):=20=EB=A9=B4=EC=A0=81=20=EC=98=A4?= =?UTF-8?q?=EB=A5=B8=EC=AA=BD=20=ED=8C=A8=EB=84=90=20=EB=B9=88=20=EC=B9=B8?= =?UTF-8?q?=20=3D=20=EC=88=98=EB=9F=89=20=EC=97=86=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20area=5Fvalues=20=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EA=B7=9C=EC=B9=99(=EC=8B=9D=20=EC=B9=B8=20=3D=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=88=98=20=C2=B7=20=EA=B7=B8=20=EB=B0=96?= =?UTF-8?q?=20=3D=20=EC=B9=B8=20=EA=B0=92=20=C2=B7=20=EB=B9=88=20=C2=B7=20?= =?UTF-8?q?=EA=B8=80=20=EB=B9=A0=EC=A7=90)=20=C2=B7=20=E3=80=8C=EC=88=98?= =?UTF-8?q?=EB=9F=89=20A1=20=3D=20=EB=B9=88=20=EC=B9=B8=E3=80=8D=20=C2=B7?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20(PLAN=2039-8=20=E2=91=A1)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet.ts | 6 ++- .../M02_MasterTemplete_AreaLink.ts | 2 + .../M02_MasterTemplete_AreaPanel.ts | 24 ++++++++--- .../M02_MasterTemplete_Labor_Rows.ts | 9 +++- .../M02_MasterTemplete_Labor_Text.ts | 1 + resources/tester/test_m02_area_panel.py | 41 +++++++++++++++++++ resources/tester/test_m02_labor_values.py | 2 + 7 files changed, 76 insertions(+), 9 deletions(-) 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 와 같음) "", ] From 88ce573e6301766fed71dcc83be8b428938eea90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(M02):=20=EC=B9=B4=EB=93=9C=20[=EC=95=8C?= =?UTF-8?q?=EC=95=BD]=20=EC=B7=A8=ED=96=A5=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EB=94=B0=EB=A6=84=20=E2=80=94=20=EA=B3=84=EC=A0=95=20=EC=B7=A8?= =?UTF-8?q?=ED=96=A5=20=EB=B0=9B=EC=9D=80=20=EB=92=A4=EC=97=90=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BC(=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=98=9B=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EB=8D=AE=EC=A7=80=20=EC=95=8A=EA=B2=8C)?= =?UTF-8?q?=20=C2=B7=20=EC=B9=B4=EB=93=9C=EB=8A=94=20=EC=97=B4=20=EB=95=8C?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?(PLAN=2039-8=20=E2=91=A2)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/b_page_state.ts | 27 +++++++++++++------ .../M02_MasterTemplete_ItemCards.ts | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 3e3f493ea..2c411ffe8 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -299,6 +299,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs"; const PREFS_PUSH_DELAY_MS = 1500; let applyingServerPrefs = false; let prefsPushTimer: ReturnType | null = null; +/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */ +let prefsPull: Promise | null = null; function localPrefs(): Record { const out: Record = {}; @@ -320,17 +322,26 @@ function pushUiPrefs(): void { if (prefsPushTimer) clearTimeout(prefsPushTimer); prefsPushTimer = setTimeout(() => { prefsPushTimer = null; - void fetch(PREFS_ENDPOINT, { - method: "PUT", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prefs: localPrefs() }), - }).catch(() => undefined); + void (prefsPull ?? Promise.resolve()) + .then(() => + fetch(PREFS_ENDPOINT, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prefs: localPrefs() }), + }), + ) + .catch(() => undefined); }, PREFS_PUSH_DELAY_MS); } -/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */ -export async function syncUiPrefs(): Promise { +/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */ +export function syncUiPrefs(): Promise { + prefsPull = pullUiPrefs(); + return prefsPull; +} + +async function pullUiPrefs(): Promise { let stored: Record = {}; try { const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 1a3faea5d..deb7368db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -478,6 +478,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름 if (col.id !== column) started.clear(); column = col.id; label = col.label;