From c535de7b315193cdb67f65231e934065f6531acf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:00 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix(M02):=20=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=A8=EB=84=90=20=EB=B9=88=20?= =?UTF-8?q?=EC=B9=B8=20=3D=20=EC=88=98=EB=9F=89=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=84=9C=EB=B2=84=20area=5Fvalues=20=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B7=9C=EC=B9=99(=EC=8B=9D=20=EC=B9=B8?= =?UTF-8?q?=20=3D=20=EA=B3=84=EC=82=B0=20=EC=88=98=20=C2=B7=20=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=96=20=3D=20=EC=B9=B8=20=EA=B0=92=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=C2=B7=20=EA=B8=80=20=EB=B9=A0=EC=A7=90)=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=88=98=EB=9F=89=20A1=20=3D=20=EB=B9=88=20=EC=B9=B8=E3=80=8D?= =?UTF-8?q?=20=C2=B7=20=EB=B0=98=EC=98=81=20=E2=80=94=20(PLAN=2039-8=20?= =?UTF-8?q?=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 02/12] =?UTF-8?q?fix(M02):=20=EC=B9=B4=EB=93=9C=20[?= =?UTF-8?q?=EC=95=8C=EC=95=BD]=20=EC=B7=A8=ED=96=A5=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=EB=94=B0=EB=A6=84=20=E2=80=94=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=B7=A8=ED=96=A5=20=EB=B0=9B=EC=9D=80=20=EB=92=A4=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=BC(=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EC=98=9B=20=EA=B0=92=EC=9D=B4=20=EB=8D=AE=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C)=20=C2=B7=20=EC=B9=B4=EB=93=9C=EB=8A=94=20=EC=97=B4?= =?UTF-8?q?=20=EB=95=8C=EB=A7=88=EB=8B=A4=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20(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; From 68f1c20e65fb7c4605e3b5feee7f24faa96948f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:09 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix(M01):=20=EC=9E=90=EC=9E=AC=20=C2=B7?= =?UTF-8?q?=20=EC=A7=81=EC=A2=85=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EB=82=B1=EB=A7=90=EC=9D=B4=20=ED=82=A4?= =?UTF-8?q?(MT=E2=80=A6)=EC=99=80=20=EA=B0=99=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=A7=9E=EC=9D=8C(39-8=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B8=80=20=E3=80=8C=EC=9D=B4=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20=C2=B7=20=EC=BD=94=EB=93=9C=E3=80=8D=20?= =?UTF-8?q?=EC=99=80=20=EB=A7=9E=EC=B6=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이름 · 규격 낱말 AND · 띄어쓰기는 그대로 · 원본 자료는 읽기만 - M02 일위대가 [자재 추가] · M01 자재 고르기가 같은 길(/api/m01/pick) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M01_MasterData/M01_MasterData_Store.py | 4 ++-- resources/tester/test_m01_api.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 8f7b2d789..0e95c8319 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -470,7 +470,7 @@ def _lowest(row: dict) -> object: def pick(kind: str, q: str, limit: int) -> dict: """고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종 - (미확보·추정 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄.""" + (미확보·추정 빼고 모든 구분)(ref = 키) · 낱말마다 이름·규격에 들어 있거나 키와 같은 줄.""" if kind not in _PICK: raise StoreError(404, f"없는 고르기 「{kind}」") words = [_squash(w) for w in q.split()] @@ -481,7 +481,7 @@ def pick(kind: str, q: str, limit: int) -> dict: ): continue text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}") - if all(w in text for w in words): + if all(w in text or w == _squash(row.get("키")) for w in words): hits.append( { "ref": str(row.get("키")), diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 094462d2d..de50867eb 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -341,6 +341,13 @@ def test_고르기_자재품목_직종(client: TestClient) -> None: market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=50) # 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨 assert any(i["관급"] and i["값"] > 0 for i in market["items"]) + # 키로도 찾음(찾기 칸 글 「이름 · 규격 · 코드」) · 이름 낱말 AND 는 그대로 + cement = _get(client, "/api/m01/pick", kind="price", q="시멘트 전국")["items"] + code = cement[0]["ref"] + by_code = _get(client, "/api/m01/pick", kind="price", q=code.lower())["items"] + assert [i["ref"] for i in by_code] == [code] + assert _get(client, "/api/m01/pick", kind="price", q=f"시멘트 {code}")["total"] == 1 + assert _get(client, "/api/m01/pick", kind="price", q=f"없는낱말 {code}")["total"] == 0 job = _get(client, "/api/m01/pick", kind="job", q="보통인부") assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == key_of("LB:1002") assert _get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"] == key_of( From 4e1b7cc5ef2c80128b2968de3e8d67a09967e003 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:29:58 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix(M02):=20=EB=82=98=EB=AC=B4=EA=B0=80?= =?UTF-8?q?=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=A2=8C=EC=B8=A1=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EC=97=B4=20=EC=A4=84=EB=8F=84=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=9B=EC=9D=8C(39-8=20H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 onChanged 한 길 — 집계표 다시 열기와 함께 side.refresh(열 줄 · 번호) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts | 5 ++++- resources/tester/test_m02_labor_tree.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index b41320543..cfa2c9eeb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -97,7 +97,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer, item.열), settle: () => main.settle(), - onChanged: (colId) => main.openTable(layer, colId, true), + // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호 + onChanged: async (colId) => { + await Promise.all([main.openTable(layer, colId, true), side.refresh()]); + }, }); const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index e77d15a6d..50f51fcad 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -53,3 +53,13 @@ def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + + +def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음(): + """39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` → + `onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴.""" + page = _text("M02_MasterTemplete_UI_Page.ts") + changed = page.split("onChanged: async (colId) => {")[1].split("},")[0] + assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed + panel = _text("M02_MasterTemplete_TreePanel.ts") + assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침 From 31d9a099be894e96f8637e71313b963a1d2327fb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:30:36 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix(M02):=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20=E2=80=94=20=EA=B7=B8=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EB=AC=B8=EC=84=9C=EA=B0=80=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A4=84=EC=9D=80=20=ED=9D=90=EB=A6=AC=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EA=B9=8C=EB=8B=AD=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=C2=B7=20=ED=99=95=EC=9D=B8=20=EC=B0=BD?= =?UTF-8?q?=20=C2=B7=20=EB=B3=B5=EC=82=AC=20=EC=95=88=20=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-8 H3 — 옮기기 전 층(일위대가 labor 없음 등)에서 고르면 「없는 양식」 으로 실패하던 것 · missingPart(도면 · 면적산출 · 일위대가 · 항목 통째는 막지 않음). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 34 ++++++++++++++++--- .../M02_MasterTemplete_ItemPick.css | 6 ++++ .../M02_MasterTemplete_ItemPick.ts | 2 ++ resources/tester/helper_m02_item_list.cjs | 4 +++ resources/tester/test_m02_item_list.py | 9 +++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 4d015ca6b..c52f0345c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -7,7 +7,7 @@ * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). * ========================================================================== */ -import { createInputField, el } from "@ui/ui_template_elements"; +import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; @@ -89,6 +89,16 @@ export const ITEM_COLS: Record[]> = { ], }; +const PART_NAME = { structure: "도면", area: "면적산출", labor: "일위대가" } as const; + +/** 그 부분 문서가 없는 줄의 까닭(항목 하나 복사 · 39-8 H3) — 옮기기 전 층의 일위대가처럼 파일이 없으면 + * 가져올 것이 없음 · 항목 통째(복사 생성)는 없는 문서를 빈 문서로 세워 막지 않음 · 있으면 null */ +export function missingPart(kind: ListKind, row: ItemRow): string | null { + if (kind === "item") return null; + const has = kind === "labor" ? !!row.labor : !!row.수정일?.[kind]; + return has ? null : `이 항목에는 ${PART_NAME[kind]} 문서가 없음`; +} + /** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ export function pageOf( rows: R[], @@ -131,7 +141,12 @@ export function buildAllItems( export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ - show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + show: ( + rows: R[], + cols: Col[], + onPick: (row: R) => void, + blocked?: (row: R) => string | null, + ) => void; /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ message: (text?: string) => void; } @@ -147,7 +162,12 @@ export function buildItemList(): ItemListHandle { grid.replaceChildren(el("p", { className: "m01-master__empty", text })); }; - const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + const show = ( + rows: R[], + cols: Col[], + onPick: (row: R) => void, + blocked?: (row: R) => string | null, + ): void => { page = 1; draw = (): void => { const got = pageOf(rows, cols, search.value, page); @@ -167,7 +187,13 @@ export function buildItemList(): ItemListHandle { return el("td", { text, attrs: { title: text } }); }), }); - tr.addEventListener("click", () => onPick(row)); + // 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함) + const why = blocked?.(row) ?? null; + if (why) { + tr.classList.add("is-blocked"); + tr.title = why; + } + tr.addEventListener("click", () => (why ? showToast(why, "info") : onPick(row))); return tr; }); grid.replaceChildren( diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index a6713f59b..de02c066e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -33,3 +33,9 @@ flex-wrap: wrap; gap: var(--spacing-8); } + +/* 그 부분 문서가 없는 줄(39-8 H3) — 흐리게 · 누르면 까닭만 */ +.m02-itemlist .m01-master__table-row.is-blocked { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 4fd1b1215..7d010b5f8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -15,6 +15,7 @@ import { buildItemList, ITEM_COLS, loadItems, + missingPart, type Col, type ItemRow, type ListKind, @@ -141,6 +142,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { rows.filter((row) => !opt.skip?.(from, row)), ITEM_COLS[opt.kind], (row) => opt.onPick({ from, row }), + (row) => missingPart(opt.kind, row), // 그 부분 문서 없는 줄 = 흐림 · 까닭(39-8 H3) ); } catch (error) { if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs index 875074b62..dcf2777a7 100644 --- a/resources/tester/helper_m02_item_list.cjs +++ b/resources/tester/helper_m02_item_list.cjs @@ -61,5 +61,9 @@ process.stdout.write( ), pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + // 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음 + missing: ["structure", "area", "labor", "item"].map((k) => + rows.map((r) => m.missingPart(k, r)), + ), }), ); diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index ccdfa1f68..935b8576c 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -67,6 +67,15 @@ def test_찾기_쪽(ts): assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 +def test_부분_문서_없는_줄_까닭(ts): + """39-8 H3 — 그 부분 문서가 없으면 고르기 막음(흐림 · 까닭) · 항목 통째는 막지 않음.""" + structure, area, labor, item = ts["missing"] + assert structure == [None, None, "이 항목에는 도면 문서가 없음"] + assert area == ["이 항목에는 면적산출 문서가 없음", None, "이 항목에는 면적산출 문서가 없음"] + assert labor == [None, None, "이 항목에는 일위대가 문서가 없음"] + assert item == [None, None, None] + + def test_서버_목록_칸_수정일_labor(world): t = Tree(_as(world), "personal") t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") From 524dc8febc94a68bd01b0b94b4a6b8ae575a4be7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:37:24 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix(M02):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EC=B8=B5=20=EC=9D=BD=EA=B8=B0=EB=8F=84=20=EC=B8=B5=20?= =?UTF-8?q?=EA=B8=B8=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20=E3=80=8C=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EA=B6=8C=ED=95=9C=E3=80=8D=20=EB=A7=89?= =?UTF-8?q?=ED=9E=98=20=ED=92=82=20(39-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Api_Fetch: 시스템 층도 /layers/system/… (양식 목록 · 읽기 · 저장 · 지우기 · 번호 · 장부 · 새 도면 · 복사) — 옛 /api/m02/templates/* 부름 0 - 서버: GET /layers/system/structure-options(읽기 로그인 누구나 · 고쳐 씀은 옛 길 그대로) · 층 양식 목록에 쓰는곳(지우기 확인 전 막음) - 「〇〇로 복사…」 폴더 창 칸 글 「복사할 폴더」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 20 +++-- .../M02_MasterTemplete_Router_Items.py | 8 ++ .../M02_MasterTemplete_Router_Layers.py | 2 + .../M02_MasterTemplete_TreePanel_CopyTo.ts | 4 +- .../M02_MasterTemplete_TreePanel_Dialogs.ts | 6 +- resources/tester/test_m02_system_read.py | 76 +++++++++++++++++++ ui_template/ui_template_locale_m2.ts | 1 + 7 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_m02_system_read.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 4c34f3e76..5bc26211c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -54,7 +54,8 @@ function reason(detail: unknown, status: number): string { return lines.filter(Boolean).join(" · ") || `HTTP ${status}`; } -/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) · +/** 지금 화면의 층 — 시스템 · 회사 · 개인 모두 `/layers/{층}` 아래(구조물집계표 · 조합 길 모두 · 읽기는 + * 로그인 누구나 · 쓰기는 서버 층 권한 · 옛 `/api/m02/templates/*` 는 시스템 관리자 전용이라 안 씀) · * `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) — * 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */ let activeLayer: Layer = "system"; @@ -73,24 +74,20 @@ const owned = (layer: Layer, path: string): string => ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` : path; const scoped = (path: string, init: RequestInit = {}): Promise => - call( - owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`), - init, - ); + call(owned(activeLayer, `/layers/${activeLayer}${path}`), init); const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); -const head = (layer: Layer): string => - layer === "system" ? "/templates" : `/layers/${layer}/templates`; +const head = (layer: Layer): string => `/layers/${layer}/templates`; const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); -/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */ +/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */ export const listTemplates = async ( layer: Layer, projectId: string | null, ): Promise => { const got = await call( - layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`), + owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`), ); return Array.isArray(got) ? got : got.양식; }; @@ -114,7 +111,7 @@ export const saveTemplate = ( /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => - call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + call(`${head("system")}/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); /** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */ export const deleteLayerTemplate = ( @@ -204,7 +201,8 @@ export interface OptionsDoc { 종류: OptionKind[]; } -export const fetchStructureOptions = (): Promise => call("/structure-options"); +export const fetchStructureOptions = (): Promise => + call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자) /** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index ceaf54a23..89fb986df 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -288,6 +288,14 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen return _call(_root(session, layer, owner), store.structure_numbers) +@router.get("/layers/system/structure-options") +def get_structure_options(session: Session = Depends(verify_session)) -> dict: + """구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만).""" + from M02_MasterTemplete import M02_MasterTemplete_Options as options + + return _call(_root(session, "system"), options.listing) + + @router.get("/layers/{layer}/structure-combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.read_combos) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 52667b2fe..639f400c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -276,6 +276,8 @@ async def list_layer( manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") + if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 + row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts index 38b03c5f6..ae6ab3e8b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -4,6 +4,7 @@ * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. * ========================================================================== */ +import { t as L } from "@ui/ui_template_locale"; import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; @@ -22,5 +23,6 @@ export async function askCopyFolder( ): Promise { const view = await fetchTree(to.layer, to.owner).catch(() => null); const folders = view ? listFolders(buildFolders(view)) : []; - return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); + const title = to.label.replace("{n}", String(n)); + return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder")); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index ab090480f..73c701f68 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -142,12 +142,14 @@ export function askNewItem(opt: { }); } -/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */ +/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 · + * `fieldLabel` = 칸 글(복사는 「복사할 폴더」) */ export function askFolder( title: string, folders: string[][], blocked: (path: string[]) => boolean, current: string[], + fieldLabel: string = L("M02_TreeMoveTo"), ): Promise { return new Promise((resolve) => { let done = false; @@ -164,7 +166,7 @@ export function askFolder( beforeClose: () => (finish(null), true), mount: (body, close) => { const field = createSelectField({ - label: L("M02_TreeMoveTo"), + label: fieldLabel, options: choices.map((path, i) => ({ value: String(i), text: path.length ? path.join(" / ") : L("M02_TreeTop"), diff --git a/resources/tester/test_m02_system_read.py b/resources/tester/test_m02_system_read.py new file mode 100644 index 000000000..b86aaf039 --- /dev/null +++ b/resources/tester/test_m02_system_read.py @@ -0,0 +1,76 @@ +"""마스터 층 읽기는 층 길 한 벌(39-8 · sub1 검증 흠 H2) — 회사 관리자 · 일반도 `/layers/system/…` 로 +표 · 카드 · 일위대가 · 번호 · 장부 · 옵션을 읽음(200) · 쓰기는 서버가 그대로 막음(403). + +시스템 층 = 견본 표(임시 폴더) · storage = 임시 폴더. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SYS = "/api/m02/layers/system" +TABLE = f"{SYS}/templates/table/구조물집계표" + + +@pytest.fixture() +def world(tmp_path, monkeypatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.include_router(layers_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session} + + +@pytest.mark.parametrize("role", ["USER", "ADMIN", "SYSTEM_ADMIN"]) +def test_세_계정_모두_마스터_층_읽기_200(world, role): + world["session"]["role"] = role + client = world["client"] + for url in ( + f"{SYS}/templates", + TABLE, + f"{SYS}/templates/structure/rv", + f"{SYS}/templates/labor/rv", + f"{SYS}/templates/area/rv", + f"{SYS}/structures/numbers", + f"{SYS}/structure-combos", + f"{SYS}/structure-options", + ): + assert client.get(url).status_code == 200, url + rows = client.get(f"{SYS}/templates").json()["양식"] + table = next(r for r in rows if (r["종류"], r["이름"]) == ("table", "구조물집계표")) + assert table["쓰는곳"] # 지우기 확인 창 전에 막는 글 + + +@pytest.mark.parametrize("role", ["USER", "ADMIN"]) +def test_회사_관리자_일반은_마스터_쓰기_403(world, role): + world["session"]["role"] = role + client = world["client"] + got = client.get(TABLE).json() + put = client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}) + assert put.status_code == 403 + assert client.post(f"{SYS}/structures/c1").status_code == 403 + assert client.post(f"{SYS}/labor/rv/copy?from=rv").status_code == 403 + gone = client.delete(f"{SYS}/templates/area/rv?판=x") + assert gone.status_code == 403 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 670d97500..78aa7c1c7 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -143,6 +143,7 @@ export const ui_locales_m2 = { M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], M02_TreeMoveTo: ["옮길 폴더", "Move to"], + M02_TreeCopyFolder: ["복사할 폴더", "Copy to folder"], M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"], M02_TreeOk: ["확인", "OK"], M02_TreeCancel: ["취소", "Cancel"], From 26a24889e6f8a183f023d797fd6c561415f5425d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:49:28 +0900 Subject: [PATCH 07/12] =?UTF-8?q?test(M02):=20=EC=B8=B5=20=EA=B8=B8=20?= =?UTF-8?q?=EC=A0=91=EB=91=90=EC=96=B4=20=EC=8B=9C=ED=97=98=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B8=B5=EB=8F=84=20/layers/syst?= =?UTF-8?q?em/=E2=80=A6=20=EA=BC=B4=20=EA=B8=B0=EB=8C=80=20(39-8=20H2=20?= =?UTF-8?q?=EB=92=A4)?= 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_01EYJJRCfHfdCGNhmXTjQKV7 --- resources/tester/test_m02_layer_screen.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 80e1bf0f8..9e3fb2227 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -206,7 +206,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): def test_층_길_접두어와_고정_풀림(): api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") - assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api + # 시스템 층도 층 길 한 벌(39-8 H2 · 옛 /templates/* 는 시스템 관리자 전용) + assert "owned(activeLayer, `/layers/${activeLayer}${path}`)" in api + assert 'activeLayer === "system" ? "" :' not in api for path in ("/structures/numbers", "/structure-combos"): assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path assert "/layers/personal/items/copy" in api From d4458ec8949d148bb3feb6c586dde043b55568df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:05:58 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=AA=A9=EB=A1=9D=20=ED=95=A9=EA=B3=84=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=EC=83=88=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EB=91=90=20=EB=8B=A8=EC=B6=94=20=E2=80=94=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EA=B8=B0=EC=A4=80=20=ED=95=A9?= =?UTF-8?q?(=EC=9E=91=EC=9D=80=20=EA=B8=80=20=E3=80=8C=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=E3=80=8D)=20=C2=B7=20=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=200=20=E3=80=8C=E2=80=94=E3=80=8D=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=3D=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=8B=A8=EC=9C=84=20=C2=B7=20=EB=88=84?= =?UTF-8?q?=EB=A5=B8=20=EB=8B=A8=EC=B6=94=EA=B0=80=20=EC=A7=84=ED=95=98?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-8 B2 · B3 · B4 — 서버 목록 칸 labor 에 기준 · 단위 · laborSum · Col note · buildTwoWay choose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 35 ++++++++++++++++--- .../M02_MasterTemplete_ItemPick.ts | 15 ++++++-- .../M02_MasterTemplete_Items.py | 22 ++++++++---- resources/tester/helper_m02_item_list.cjs | 9 ++++- resources/tester/test_m02_item_list.py | 33 ++++++++++++++--- 5 files changed, 96 insertions(+), 18 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index c52f0345c..1df2b007c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -22,12 +22,21 @@ export interface ItemRow extends TreeItem { 도번?: string | null; 면적번?: string | null; 수정일?: Partial>; - labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; + labor?: { + 번호: string | null; + 로직수: number; + /** 수량 반영 합 · 기준(단가) 합 · 일위대가 문서 단위 */ + 합계: number | null; + 기준?: number | null; + 단위?: string | null; + } | null; } export interface Col { head: string; get: (row: R) => string; + /** 값 뒤 작은 글(예 「기준」) — 없으면 없음 */ + note?: (row: R) => string; } const TEXT = { @@ -51,6 +60,17 @@ const latest = (row: ItemRow): string => .pop(), ); +/** 일위대가 합계(39-8 B2) — 수량 반영 합이 있으면 그것 · 없으면 기준(단가) 합 + 작은 글 「기준」 · + * 담은 로직 0 · 계산값 없음 = 「—」 */ +export function laborSum(row: ItemRow): { text: string; note: string } { + const l = row.labor; + if (!l?.로직수) return { text: DASH, note: "" }; + if (l.합계) return { text: money(l.합계), note: "" }; + return typeof l.기준 === "number" + ? { text: money(l.기준), note: "기준" } + : { text: DASH, note: "" }; +} + const NAME: Col = { head: "이름", get: (r) => leafName(r) }; const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; @@ -73,9 +93,9 @@ export const ITEM_COLS: Record[]> = { { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, NAME, FOLDER, - UNIT, + { head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3) { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, - { head: "합계", get: (r) => money(r.labor?.합계) }, + { head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note }, changed("labor"), ], item: [ @@ -184,7 +204,14 @@ export function buildItemList(): ItemListHandle { className: "m01-master__table-row", children: cols.map((c) => { const text = c.get(row); - return el("td", { text, attrs: { title: text } }); + const note = c.note?.(row) ?? ""; + return el("td", { + text, + attrs: { title: note ? `${text} ${note}` : text }, + children: note + ? [el("small", { className: "m01-master__muted", text: ` ${note}` })] + : [], + }); }), }); // 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 7d010b5f8..34deef6f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -185,10 +185,21 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement { const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); const below = el("div", { className: "m02-itempick__below" }); let busy = false; - fresh.addEventListener("click", opt.onNew); + /** 누른 단추 = 지금 화면 — 진한 단추가 고른 쪽 · 복사 생성 목록은 그쪽일 때만 보임 */ + const choose = (which: HTMLElement): void => { + for (const b of [fresh, copy]) { + b.classList.toggle("ui-btn--filled", b === which); + b.classList.toggle("ui-btn--ghost", b !== which); + } + below.hidden = which !== copy; + }; + fresh.addEventListener("click", () => { + choose(fresh); + opt.onNew(); + }); copy.addEventListener("click", () => { + choose(copy); if (below.firstChild) return; - copy.classList.add("is-active"); const picker = buildPicker({ ...opt.picker, onPick: (picked) => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index b3899ec42..4edfb4f89 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -86,9 +86,19 @@ def items(folder: Path) -> list[dict[str, Any]]: return out +def _number(sums: Any) -> float | None: + """`계산값` 합 칸의 `계` — 수 아닌 값(옛 꼴) · 없음 = null(「—」).""" + total = sums.get("계") if isinstance(sums, dict) else None + try: + return None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + return None + + def _list_extras(folder: Path, col: str) -> dict[str, Any]: """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 - 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계`) · 기준(`계산값.기준합.계`) · 단위 — + 파일이 없으면 칸 없음.""" stamps: dict[str, str] = {} for kind in ("structure", "area", "labor"): path = folder / kind / f"{col}.json" @@ -103,15 +113,13 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]: doc = json.loads(path.read_bytes()) except ValueError: doc = {} - total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") - try: - total = None if total is None or isinstance(total, bool) else float(total) - except (TypeError, ValueError): - total = None # 수 아닌 값(옛 꼴)은 「—」 + calc = doc.get("계산값") or {} labor = { "번호": doc.get("일위대가번"), "로직수": len(doc.get("담은로직") or []), - "합계": total, + "합계": _number(calc.get("반영합")), + "기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2) + "단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3) } return {"수정일": stamps, "labor": labor} diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs index dcf2777a7..b1f6baee2 100644 --- a/resources/tester/helper_m02_item_list.cjs +++ b/resources/tester/helper_m02_item_list.cjs @@ -33,7 +33,7 @@ const row = (열, 경로, extra = {}) => ({ }); const rows = [ row("c1", ["기슭막이", "메쌓기", "1.2m"], { - labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4, 기준: 99, 단위: "개소" }, 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, }), row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), @@ -62,6 +62,13 @@ process.stdout.write( pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, // 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음 + sums: [ + rows[0], // 반영 합 있음 + { labor: { 로직수: 14, 합계: 0, 기준: 167746.6 } }, // 수량 없음 = 기준 + rows[1], // 담은 로직 0 + { labor: { 로직수: 2, 합계: null, 기준: null } }, // 계산값 없음 + rows[2], // 일위대가 없음 + ].map((r) => m.laborSum(r)), missing: ["structure", "area", "labor", "item"].map((k) => rows.map((r) => m.missingPart(k, r)), ), diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 935b8576c..604f3d7ca 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -54,11 +54,23 @@ def test_칸_값_빈_번호_합계_날짜(ts): s, labor = ts["cells"]["structure"], ts["cells"]["labor"] assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) - assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[0][3:] == ["개소", "3", "1,234,567", "2026-10-04"] # 일위대가 단위 · 원 단위 assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 +def test_일위대가_합계_반영_기준_빈(ts): + """39-8 B2 — 반영 합이 있으면 그것 · 없으면 기준 합 + 「기준」 · + 담은 로직 0 · 계산값 없음 = 「—」.""" + assert ts["sums"] == [ + {"text": "1,234,567", "note": ""}, + {"text": "167,747", "note": "기준"}, + {"text": "—", "note": ""}, + {"text": "—", "note": ""}, + {"text": "—", "note": ""}, + ] + + def test_찾기_쪽(ts): assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 @@ -91,14 +103,27 @@ def test_서버_목록_칸_수정일_labor(world): doc = { "일위대가번": "SU-000001", "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], - "계산값": {"반영합": {"계": 12345.6}}, + "단위": "개소", + "계산값": {"반영합": {"계": 12345.6}, "기준합": {"계": 2469.12}}, } (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} - assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert rows[ditch["열"]]["labor"] == { + "번호": "SU-000001", + "로직수": 2, + "합계": 12345.6, + "기준": 2469.12, + "단위": "개소", + } assert "labor" in rows[ditch["열"]]["수정일"] - assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + assert rows[wall["열"]]["labor"] == { + "번호": None, + "로직수": 0, + "합계": None, + "기준": None, + "단위": None, + } doc["계산값"] = {"반영합": {"계": "글"}} (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} From 6541b6583488e2e5fbff59336de5f1658f82eeb2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:08:06 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=ED=91=9C=20=E3=80=8C=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=E3=80=8D=20=EC=A4=84=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=EC=97=B4=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=EB=B2=88(SU)?= =?UTF-8?q?=20=E2=80=94=20=EC=98=9B=20=EC=A1=B0=ED=95=A9=20=ED=82=A4(UA)?= =?UTF-8?q?=20=EC=B9=B8=20=EC=95=88=20=EC=9D=BD=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B8=80=20=EC=A4=84(=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 부품: 일위대가 줄도 번호 글 줄(손으로 못 침 · laborLabel) · 조회 후 선택이 열 칸을 안 고침 - 누르면 다른 줄과 같은 길(일위대가 복사해 가져오기) · 열의 옛 「일위대가」 값은 파일에 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_UI_Main.ts | 1 + resources/tester/test_sheet_drawing_select.py | 16 +++++++++++++++ ui_template/sheet/ui_template_sheet.ts | 15 ++++++-------- ui_template/sheet/ui_template_sheet_ops.ts | 6 +++--- ui_template/sheet/ui_template_sheet_render.ts | 20 ++++++++++--------- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 450fc31a7..d70207bdb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -329,6 +329,7 @@ export function createMain( onChange: () => (dirty = !readOnly), ...(struct ? { + laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "", drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", combo: { diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 2909841c4..133f770b1 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -8,6 +8,7 @@ master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없 from __future__ import annotations import json +import re import shutil import subprocess from pathlib import Path @@ -70,3 +71,18 @@ def test_text_column_pick_rows_blank(): assert _run()["blank"] == [ [True, False], [True, False], [True, False], [False, False], [False, False], ] # fmt: skip + + +def test_labor_row_shows_su_number(): + """마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음.""" + ops = OPS.read_text(encoding="utf-8") + assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops + render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8") + assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render) + assert "col.일위대가" not in render + sheet = (OPS.parent / "ui_template_sheet.ts").read_text(encoding="utf-8") + assert "col.일위대가" not in sheet # 읽기 · 쓰기 · 조회 후 선택 모두 안 건드림 + main = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Main.ts").read_text( + encoding="utf-8" + ) + assert 'laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? ""' in main diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 35372ff8a..b8f38ebe6 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -25,7 +25,6 @@ import { addLevel, deleteColumn, isBlankPick, - KEY_PRICE, KEY_UNIT, navRows, PICK_ROWS, @@ -54,6 +53,8 @@ export interface SheetOptions { /** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */ lockHead?: boolean; onChange?: (doc: SheetDoc) => void; + /** 일위대가 줄 칸 글(일위대가번 SU · 열 id) — `drawingLabel` 과 같음(열의 옛 `일위대가` 칸은 안 읽음). */ + laborLabel?: (colId: string) => string; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; /** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */ @@ -61,8 +62,7 @@ export interface SheetOptions { /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름. - * 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`. - * 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */ + * 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()` · 없으면 읽기만. */ onPick?: (sel: SheetSelection) => Promise | void; /** 구조물 조합 표(PLAN 31-1) — 단위 줄 · 안내 글 없음 · 열 폭 자동 · 고른 열은 가장 깊은 이름 칸을 밝힘 · * `onHeadEdit` 를 주면 이름 칸을 두 번 누르기 · F2 · Enter 로 고쳐 부른 쪽에 넘김(표 문서는 안 고침 — @@ -87,6 +87,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio mode: opts.mode, result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, + laborLabel: opts.laborLabel, drawingLabel: opts.drawingLabel, areaLabel: opts.areaLabel, pick: !!opts.onPick, @@ -236,7 +237,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio // ── 칸 고치기 ───────────────────────────────────────────────────── const rawText = (key: string, col: SheetColumn): string => { if (key === KEY_UNIT) return col.단위 ?? ""; - if (key === KEY_PRICE) return col.일위대가 ?? ""; if (REF_ROWS.includes(key)) return refText(state, key, col.id); const row = state.doc.줄.find((r) => `d:${r.id}` === key); const formula = row?.식?.[col.id] ?? col.식; @@ -248,15 +248,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (ro || REF_ROWS.includes(key)) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; - else if (key === KEY_PRICE) col.일위대가 = value || null; else setCell(state.doc, key.slice(2), col, text); changed(); }; - /** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */ + /** 조회 후 선택 — 부른 쪽이 가져옴(번호 글 줄 · 문서는 안 고침) */ const pick = async (key: string, colId: string): Promise => { - const got = await opts.onPick?.(selectionOf(state.doc, key, colId)); - const col = colOf(colId); - if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got); + await opts.onPick?.(selectionOf(state.doc, key, colId)); root.focus({ preventScroll: true }); }; /** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */ diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index a3d807da6..0f0a51ea2 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -22,11 +22,11 @@ export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; export const KEY_AREA = "s:area"; -/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ +/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; -/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */ -export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA]; +/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */ +export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ export const PICK_ROWS: readonly string[] = MASTER_ROWS; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 3b5113938..e9992bbf7 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -17,6 +17,7 @@ import { isBlankPick, isCalcColumn, KEY_AREA, + KEY_PRICE, KEY_UNIT, MASTER_ROWS, type MasterRowKey, @@ -38,7 +39,8 @@ export interface RenderState { mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; - /** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + /** 일위대가 · 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + laborLabel?: (colId: string) => string; drawingLabel?: (colId: string) => string; areaLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ @@ -51,9 +53,13 @@ export interface RenderState { headEditable?: boolean; } -/** 도면 · 면적산출 줄 칸 글. */ +/** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */ export const refText = (state: RenderState, key: string, colId: string): string => - (key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); + (key === KEY_AREA + ? state.areaLabel + : key === KEY_PRICE + ? state.laborLabel + : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -68,7 +74,7 @@ function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map refText(state, key, col.id))]; + const texts = REF_ROWS.map((key) => refText(state, key, col.id)); const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14; mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need)); } @@ -191,11 +197,7 @@ export function renderSheet(state: RenderState): HTMLElement { const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { const blank = isBlankPick(key, col); - const text = blank - ? "" - : REF_ROWS.includes(key) - ? refText(state, key, col.id) - : (col.일위대가 ?? ""); + const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글 const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick"); From 7cdc7b08da5fd5a61f4b2af7265a72557f690b52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:18:59 +0900 Subject: [PATCH 10/12] =?UTF-8?q?chore(plan):=2040=EC=9E=A5=20M02=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=202=EC=B0=A8=20=E2=80=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9A=94=EA=B5=AC=20=EC=9B=90=EB=AC=B8?= 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_01FJqWQpoB1RE7hiaQ2wH8qV --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 From 43b1c12765d1dfbfd03f55df02f0a6dd07ffc2b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:20:03 +0900 Subject: [PATCH 11/12] auto: 2026-10-04 22:20 (ESD_LAPTOP) --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 From 4347d25c99793df4c8e98228188504a8af4593f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:20:04 +0900 Subject: [PATCH 12/12] auto: 2026-10-04 22:20 (ESD_LAPTOP) --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지