From b1dbd9c3cb331fb43e2279b91f1827238c683455 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:26:53 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(B07=20CAD):=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EB=A7=8C=EB=93=A0=20=ED=91=9C=20=EC=84=A0=20?= =?UTF-8?q?=EC=83=89=20=C2=B7=20=EA=B5=B5=EA=B8=B0=20=C2=B7=20id=20?= =?UTF-8?q?=EB=B9=84=EB=A9=B4=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=E2=80=94=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=20=C2=B7=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=97=B4=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=20(43-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableEntity.fromJson: lineColor 비면 자동색 #fff · lineWidth 비면 1 · id 비면 새 id(면적산출 → 도면 표 변환 답에 세 칸이 없음 → 저장 JSON 선 색 빠지던 흠 E2) - 시험 aislo-table-bridge.test.ts 에 선 색 없는 표 놓기 · 저장 JSON 선 색 · 다시 읽기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../openwebcad/src/entities/TableEntity.ts | 8 ++++--- .../integration/aislo-table-bridge.test.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts index 5662184dd..33767ed58 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -351,9 +351,11 @@ export class TableEntity implements Entity { data.cells, data.style ); - table.id = jsonEntity.id; - table.lineColor = jsonEntity.lineColor; - table.lineWidth = jsonEntity.lineWidth; + // 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의 + // 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다. + table.id = jsonEntity.id || table.id; + table.lineColor = jsonEntity.lineColor || table.lineColor; + table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth; table.lineDash = jsonEntity.lineDash; return table; } diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts index f11cab527..741cf8246 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -77,6 +77,28 @@ describe('표 놓기(PLACETABLE)', () => { }); }); +describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => { + it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => { + const json = await table('T8').toJson(); + if (!json) throw new Error('표 JSON 없음'); + const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json; + await handleTableMessage( + { type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' }, + vi.fn() + ); + const pending = getPendingTable(); + expect(pending?.lineColor).toBe('#fff'); + expect(pending?.lineWidth).toBe(1); + const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id); + placed.lineColor = pending?.lineColor ?? ''; + const saved = await placed.toJson(); + expect(saved?.lineColor).toBe('#fff'); + const back = await TableEntity.fromJson(bare as typeof json); + expect(back.id).toBeTruthy(); + expect(back.lineColor).toBe('#fff'); + }); +}); + describe('표 칸 갈기(update-table)', () => { it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => { const linked = table('T1'); From 5067ee4478241c90fddd2800fc837a3fcb74f44a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:27:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore(M02):=203=EC=B0=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9A=94=EA=B5=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=9B=90=EB=AC=B8(=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=97=86?= =?UTF-8?q?=EC=95=B0=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0)?= 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 --- docs/raw/M02_구조물조합개편/사용자요구_1005.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1005.md b/docs/raw/M02_구조물조합개편/사용자요구_1005.md index d872c1c82..bea9b003e 100644 --- a/docs/raw/M02_구조물조합개편/사용자요구_1005.md +++ b/docs/raw/M02_구조물조합개편/사용자요구_1005.md @@ -8,3 +8,7 @@ 4. 구조물조합에서 일위대가 미리보기에서 일위대가 상세화면의 단가산출 로직(순서와 로직추가는 제외)과 자재목록을 그대로 보여주게 하되 기능은 유지(수량산출 연결창, 옵션지정 모달창)하고 수평스크롤을 가능하게 하기 5. 일위대가의 재료도 2열구조여야 할것같은데.... 6. 도면정보, 일위대가, 면적산출을 접근 후 제목의 돌아가기 버튼은 알약이 아니라 일반 버튼으로 + +## 추가 (10-5 새벽 · 원문 그대로) + +추가 항목이 하나더 있어. 구조물 조합에서의 미리보고 3개 컨테이너에 새로만들기와 복사 생성로직이 필요없겠네. 구조물 조합의 새항목 생성을 하면 당연히 새로운 항목들이 자동으로 생성되잖아. 그후 가져오기를 하면 당연히 값을 가져와서 덮어쓰면 되겠어. 그리고 일위대가와 면적산출에서 가져오기를 하면 도면은 개별로 작성되는게 맞지만 일위대가와 면적산출은 연결되어 있는정보니 같이 가져오는게 맞겠어. 알약연결 포함해서. 이 내용도 추가해줘. From e2a938f2cfe90fc4f38585f6804b6042202c911b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:28:37 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(M02):=20=EC=83=81=EC=84=B8=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20[=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0]=20=3D=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=8B=A8=EC=B6=94=20=EB=8C=80=EB=B9=84=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4(43-5=20=C2=B7=20E1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도면 · 일위대가 · 면적산출 상세 [돌아가기] = 공용 기본(filled · 32px · 머리 단추와 같은 높이 · ghost sm 아님) - E1 대비 1.11 = ORCA 탭에서 단추 color 전환이 멈춘 채 읽힌 착오 · 전환 끄고 재면 어두움 8.01 · 밝음 16.65 → 색 고침 없음 - 시나리오 resources/tester/ui_checks/43-5_돌아가기_대비.js · 시험 한 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_UI_Main.ts | 4 +- resources/tester/test_m02_structure_panel.py | 7 ++ .../tester/ui_checks/43-5_돌아가기_대비.js | 96 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 resources/tester/ui_checks/43-5_돌아가기_대비.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 13630fac5..77d6b3146 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -130,8 +130,8 @@ export function createMain( const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") }); const reload = createButton({ label: L("M02_Reload"), variant: "ghost" }); const save = createButton({ label: L("M02_Save"), variant: "filled" }); - // 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) - const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" }); + // 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님) + const back = createButton({ label: L("M02_Back") }); const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); const host = el("div", { className: "m02-main__host" }); diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index be79312be..d73ce71e0 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -238,3 +238,10 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body assert "sheet?.selectColumn(column)" in body + + +def test_상세_머리_돌아가기는_일반_단추(): + """43-5 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] = 공용 기본 단추(filled · 같은 높이 · + 알약 · ghost · sm 아님) · 대비는 ui_checks/43-5 시나리오(어두움 8.01).""" + main = _text(MAIN) + assert 'const back = createButton({ label: L("M02_Back") });' in main diff --git a/resources/tester/ui_checks/43-5_돌아가기_대비.js b/resources/tester/ui_checks/43-5_돌아가기_대비.js new file mode 100644 index 000000000..7207f3c96 --- /dev/null +++ b/resources/tester/ui_checks/43-5_돌아가기_대비.js @@ -0,0 +1,96 @@ +/* 43-5 · E1 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] 꼴 · 머리 단추 글자 대비(밝음 · 어두움) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - 세 상세 모두 back.cls = "ui-btn ui-btn--filled"(ghost · sm 없음) · back.h = 머리 다른 단추 높이(±1px) + * - 밝음 · 어두움(앱 ☾ 단추) 모두 머리 보이는 단추 대비 ≥ 4.5 + * - 끝나면 처음 테마로 되돌림(theme.end = theme.start) + * ⚠ 단추 color 전환(120ms)이 ORCA 탭에선 0ms 에 멈춰 옛 색이 읽힘(1.11 착오 · E1) — 재는 동안 전환을 끔 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const rgb = (s) => (s.match(/[\d.]+/g) || []).map(Number); + const lum = ([r, g, b]) => { + const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + /** 실제로 보이는 바탕 — 투명이면 조상으로 */ + const bgOf = (el) => { + for (let e = el; e; e = e.parentElement) { + const c = rgb(getComputedStyle(e).backgroundColor); + if (c.length >= 3 && (c.length < 4 || c[3] > 0.5)) return c.slice(0, 3); + } + return [255, 255, 255]; + }; + const ratio = (el) => { + const a = lum(rgb(getComputedStyle(el).color).slice(0, 3)); + const b = lum(bgOf(el)); + return +((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)).toFixed(2); + }; + const theme = () => document.documentElement.getAttribute("data-theme") || "auto"; + const moon = () => + [...document.querySelectorAll("button")].find((b) => /^[☾☀]$/.test(b.textContent.trim())); + const headButtons = () => + [...document.querySelectorAll(".m02-main__head button")].filter( + (b) => !b.hidden && b.offsetParent, + ); + const openRow = async (sectionTitle, prefix) => { + const sec = [...document.querySelectorAll(".m02-side__summary > section")].find( + (s) => s.querySelector("h3").textContent.trim() === sectionTitle, + ); + sec.classList.remove("is-collapsed"); + [...sec.querySelectorAll("button")].find((b) => b.textContent.startsWith(prefix)).click(); + await w(5000); + }; + const measure = () => { + const back = [...document.querySelectorAll(".m02-main__head button")].find( + (b) => b.textContent.trim() === "돌아가기", + ); + const others = headButtons().filter((b) => b !== back); + return { + title: document.querySelector(".m02-main__title")?.textContent, + back: back && { + cls: back.className, + h: Math.round(back.getBoundingClientRect().height), + radius: getComputedStyle(back).borderRadius, + }, + otherH: others.map((b) => Math.round(b.getBoundingClientRect().height)), + contrast: Object.fromEntries( + headButtons().map((b) => [b.textContent.trim() || b.title, ratio(b)]), + ), + }; + }; + const still = document.createElement("style"); + still.textContent = "*,*::before,*::after{transition:none!important}"; + document.head.append(still); + const start = theme(); + const out = { theme: { start }, pages: {} }; + for (const [name, sec, prefix] of [ + ["도면", "구조물 도면", "SD-000001"], + ["일위대가", "일위대가", "SU-000001"], + ["면적산출", "면적산출", "SA-000001"], + ]) { + await openRow(sec, prefix); + const light = measure(); + moon()?.click(); + await w(400); + const dark = { theme: theme(), ...measure() }; + moon()?.click(); + await w(400); + out.pages[name] = { light, dark }; + } + if (theme() !== start) moon()?.click(); + out.theme.end = theme(); + still.remove(); + const all = Object.values(out.pages).flatMap((p) => [ + ...Object.values(p.light.contrast), + ...Object.values(p.dark.contrast), + ]); + out.minContrast = Math.min(...all); + out.backOk = Object.values(out.pages).every( + (p) => + p.light.back?.cls === "ui-btn ui-btn--filled" && + p.light.otherH.every((h) => Math.abs(h - p.light.back.h) <= 1), + ); + return JSON.stringify(out); +})(); From 848f8bbc931663547e5f18a17d9bc5c731e5ccb5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:33:02 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test(M02):=20=ED=99=94=EB=A9=B4=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=2043-6=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=20=EC=84=A0=20=EC=83=89=20=E2=80=94=20orca=20eval=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=86=93=EA=B8=B0=20=E2=86=92?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20JSON=20=EC=84=A0=20=EC=83=89=20=C2=B7?= =?UTF-8?q?=20=EB=90=98=EB=8F=8C=EB=A6=BC=20(43-6)?= 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_01UoSr2sqvYtBf1dtQdus88s --- .../tester/ui_checks/43-6_도면표_선색.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 resources/tester/ui_checks/43-6_도면표_선색.js diff --git a/resources/tester/ui_checks/43-6_도면표_선색.js b/resources/tester/ui_checks/43-6_도면표_선색.js new file mode 100644 index 000000000..15e939a3f --- /dev/null +++ b/resources/tester/ui_checks/43-6_도면표_선색.js @@ -0,0 +1,67 @@ +/* 43-6 도면 표 선 색(흠 E2) — orca eval 한 번에 도는 시나리오(읽기만 · 저장 안 함). + * 시작 화면: M02 · 시스템 관리자 · 구조물 도면 상세(마스터 item1 · CAD 열림 · 보기 전용 아님). + * 하는 일: 면적산출 범위 → 표 변환(POST …/area/item1/table · 답에 lineColor 없음) → CAD 에 place-table → + * 커서 옮김 → 누름(놓기) → save-request 로 CAD 도면 JSON 받음 → Ctrl+Z 로 되돌림. + * 기대: 변환 답 lineColor 없음(answerHasColor false) · 놓인 표 1 · 저장 JSON 표 lineColor = "#fff" · + * lineWidth 1 · id 있음 · 되돌린 뒤 표 수 = 시작 수. + */ +(async () => { + const W = (t) => new Promise((r) => setTimeout(r, t)); + const f = document.querySelector("iframe"); + const w = f.contentWindow; + const cad = w.__aisloCad; + const before = cad.entities().filter((e) => e.type === "Table").length; + const area = await ( + await fetch("/api/m02/layers/system/templates/area/item1", { credentials: "include" }) + ).json(); + const sheet = area.문서.시트[0].id; + const got = await ( + await fetch("/api/m02/layers/system/area/item1/table", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 시트: sheet, 범위: "A1:B2", 글자크기: 2.5 }), + }) + ).json(); + const entity = got.표; + w.postMessage({ type: "aislo:b08:place-table", entity, link: "T43" }, location.origin); + await W(600); + const c = f.contentDocument.querySelector("canvas"); + const r = c.getBoundingClientRect(); + const at = { bubbles: true, button: 0, clientX: r.left + 320, clientY: r.top + 260 }; + c.dispatchEvent(new MouseEvent("mousemove", at)); + await W(window.__hold ?? 300); // 사진 찍을 때는 __hold 를 늘림 + c.dispatchEvent(new MouseEvent("mousedown", at)); + c.dispatchEvent(new MouseEvent("mouseup", at)); + await W(500); + const placed = cad.entities().filter((e) => e.type === "Table").length - before; + let saved = null; + const take = (e) => { + if (e.data && e.data.type === "aislo:b08:save-response") saved = e.data.drawing; + }; + window.addEventListener("message", take); + w.postMessage({ type: "aislo:b08:save-request" }, location.origin); + await W(1000); + window.removeEventListener("message", take); + const table = (saved?.entities ?? []).find( + (e) => e.type === "Table" && e.shapeData?.style?.연결 === "T43", + ); + f.contentDocument.body.dispatchEvent( + new KeyboardEvent("keydown", { + key: "z", + code: "KeyZ", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + await W(400); + return JSON.stringify({ + answerHasColor: "lineColor" in entity, + placed, + lineColor: table?.lineColor ?? null, + lineWidth: table?.lineWidth ?? null, + hasId: !!table?.id, + afterUndo: cad.entities().filter((e) => e.type === "Table").length === before, + }); +})(); From bc1314e94caac02ac4fc29c485f32b8400e2152f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:33:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EC=9E=AC=20=EC=A4=84=202=ED=96=89=20?= =?UTF-8?q?=C2=B7=20=EA=B0=80=EA=B2=A9=20=EB=8B=A4=EC=84=AF(43-3)=20?= =?UTF-8?q?=E2=80=94=20M01=20=EA=B0=92=20=EC=97=B4=20=EB=8B=A4=EC=84=AF(?= =?UTF-8?q?=EB=AC=BC=EA=B0=80=EC=9E=90=EB=A3=8C=20=C2=B7=20=EC=9C=A0?= =?UTF-8?q?=ED=86=B5=EB=AC=BC=EA=B0=80=20=C2=B7=20=EB=AC=BC=EA=B0=80?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=C2=B7=20=EA=B1=B0=EB=9E=98=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20=EA=B4=80=EA=B8=89)=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EB=8B=A8=EA=B0=80=20=EC=B9=B8=20=EA=B0=95=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 calc 답 자재 줄에 가격 · 기준칸(마스터 · 회사 · 개인 층 calc · 프로젝트 복사본 calc 같은 함수 material_price) · 원본 자료는 읽기만 - 자재목록 표 = 코드 · 이름 · 규격 · 수량산출(위 1 단위 / 아래 지금 수량) · 단위 · 가격 다섯(빈 것 「—」) · 기준단가 / 반영 금액 - 화면 판정 시나리오 resources/tester/ui_checks/43-3_자재가격.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M02_MasterTemplete/M02_Item_Prices.py | 7 +- M02_MasterTemplete/M02_Labor_Doc.py | 40 +++++++---- .../M02_MasterTemplete_Labor.css | 7 ++ .../M02_MasterTemplete_Labor_Calc.ts | 4 ++ .../M02_MasterTemplete_Labor_Rows.ts | 52 +++++++------- resources/tester/test_m02_item_labor.py | 3 + resources/tester/test_m02_labor_doc.py | 3 + resources/tester/ui_checks/43-3_자재가격.js | 70 +++++++++++++++++++ 8 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 resources/tester/ui_checks/43-3_자재가격.js diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index a5ae96b66..df2b05f5c 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -325,11 +325,8 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An mats = [] for one in doc.get("자재") or []: code = str(one.get("코드") or "") - try: - price, _ = mm.element(snapshot, code, {}, one.get("단위") or None) - except mf.FormulaError: - price = None - mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price}) + got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3) + mats.append({"코드": code, "알약": labor_doc.material_key(code), **got}) settled = labor_doc.settle( to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area) ) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index eaf3979d3..1dd338ff5 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -5,7 +5,8 @@ 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 -반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. +반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) · +기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. """ @@ -247,6 +248,24 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) +def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: + """자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element` + (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만.""" + row = master.get(code) or {} + prices = {slot: row.get(slot) for slot in mm.SLOTS} + try: + price, source = mm.element(master, code, {}, unit or None) + except mf.FormulaError as e: + return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)} + slot = str(source).split(".")[-1] if source else None + return { + "기준단가": price, + "가격": prices, + "기준칸": slot if slot in mm.SLOTS else None, + "까닭": None if price is not None else "단가 없음", + } + + def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" @@ -277,17 +296,14 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) continue lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) - mats = [] - for one in doc.get("자재") or []: - try: - price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) - except mf.FormulaError as e: - price, why = None, str(e) - else: - why = None if price is not None else "단가 없음" - mats.append( - {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} - ) + mats = [ + { + "코드": one["코드"], + "알약": material_key(one["코드"]), + **material_price(whole, one["코드"], one.get("단위")), + } + for one in doc.get("자재") or [] + ] have = _area_of(doc.get("열")) if given is None else given return settle(_json({"줄": lines, "자재": mats}), doc, have) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 220c81a2a..b581f596b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -58,6 +58,13 @@ font-size: var(--text-caption); } +/* 자재 가격 다섯 — 기준단가가 된 칸(43-3) */ +.m02-labrow__price.is-base-price { + color: var(--color-accent); + font-weight: 700; + box-shadow: inset 0 -2px 0 var(--color-accent); +} + .m02-labrow__applied { display: block; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index dcff3da3c..dd9da6fe9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -28,6 +28,10 @@ export interface CalcMaterial { 코드: string; 알약?: string | null; 기준단가: number | null; + /** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */ + 가격?: Record; + /** 기준단가가 된 열 이름 — 없으면 null */ + 기준칸?: string | null; 수량?: number | null; 수량출처?: QtySource; 반영?: number | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 5189b76b1..37d415b69 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -3,7 +3,8 @@ * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. - * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). + * 금액은 calc 답(없으면 「…」). * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). * ========================================================================== */ @@ -278,7 +279,12 @@ export interface MatTableData { cells?: ReadonlyMap; } -/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */ +export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const; + +/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 + * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · + * 보기 갈래는 `on` 없이 */ export function matTable(data: MatTableData, on?: Omit): HTMLElement { const wait = !data.calc && !data.fail; const remove = on?.remove; @@ -286,7 +292,20 @@ export function matTable(data: MatTableData, on?: Omit): HT wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); const trs = data.mats.map((mat, i) => { const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); - const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + // 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1 + const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine; + const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { + const base = !!line?.기준칸 && line.기준칸 === slot; + return el("td", { + className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, + text: show(line?.가격?.[slot]), + attrs: { + "data-label": slot, + "data-price-slot": slot, + ...(base ? { title: tc("Labor_BasePrice") } : {}), + }, + }); + }; const tr = el("tr", { className: on ? "m02-labrow__row is-open" : "m02-labrow__row", attrs: { "data-labor-mat": mat.코드 }, @@ -302,29 +321,14 @@ export function matTable(data: MatTableData, on?: Omit): HT ], }), el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }), + qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "", attrs: { "data-label": tc("Labor_Unit") }, }), - el("td", { - className: "m02-labrow__num", - text: qty, - attrs: { - title: qtyText(line, mat.단위 ?? "", data.cells), - "data-label": tc("Labor_Qty"), - }, - }), - el("td", { - className: "m02-labrow__num", - text: show(line?.기준단가), - attrs: { "data-label": tc("Labor_BasePrice") }, - }), - el("td", { - className: "m02-labrow__num", - text: show(line?.반영), - attrs: { "data-label": tc("Labor_AppliedSum") }, - }), + ...PRICE_SLOTS.map(priceCell), + moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), ...(remove ? [ el("td", { @@ -351,10 +355,10 @@ export function matTable(data: MatTableData, on?: Omit): HT tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), + tc("Labor_QtyCol"), tc("Labor_Unit"), - tc("Labor_Qty"), - tc("Labor_BasePrice"), - tc("Labor_AppliedSum"), + ...PRICE_SLOTS, + `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), ]; return data.mats.length diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index ca3f2b4cb..90c0f831c 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -181,6 +181,9 @@ def test_계산_반영_합은_손셈_원본과_같음(world): (mat,) = got["자재"] assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY) + assert ( + mat["가격"][mat["기준칸"]] == mat["기준단가"] + ) # 복사본 단가 묶음으로도 가격 다섯 · 기준칸(43-3) total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"] assert got["반영합"]["계"] == pytest.approx(total) # 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것 diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index 9f2cd52b1..adee730ee 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -98,6 +98,9 @@ def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): assert (thousand["수량"], thousand["수량출처"]) == (1, "손") mat = got["자재"][0] assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000) + # 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름 + assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] + assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000 nothing = labor.calc(doc, {})["줄"][0] assert (nothing["수량출처"], nothing["반영"]) == ("없음", None) diff --git a/resources/tester/ui_checks/43-3_자재가격.js b/resources/tester/ui_checks/43-3_자재가격.js new file mode 100644 index 000000000..694b78810 --- /dev/null +++ b/resources/tester/ui_checks/43-3_자재가격.js @@ -0,0 +1,70 @@ +// 43-3 자재 줄 2행 · 가격 다섯 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎(async 식 · JSON 돌려줌). +// 시작 화면 = M02 마스터 템플릿(#/m02-master-template · 시스템 관리자 · 마스터 층). +// 하는 일 = 좌측 일위대가 SU-000001 상세 → [자재 추가] → MT004177(시멘트, 전국) 고름 → 줄 수치 읽음 → [빼기]로 되돌림(저장 안 함). +// 기대 = heads 에 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · prices = 6,545 · 6,727 · 6,090 · 6,181 · — · +// base = 물가정보(굵게 · 강조) · money.base = 6,090 · qty.base = 「1 포」 · after = 0(빼기 뒤 줄 없음). +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const got = fn(); + if (got) return got; + await wait(150); + } + return null; + }; + const CODE = "MT004177"; + const group = [...document.querySelectorAll(".m02-side__group")].find((s) => + s.querySelector(".ui-collapsible__title")?.textContent.trim().startsWith("일위대가"), + ); + if (!group) return JSON.stringify({ error: "좌측 일위대가 없음" }); + if (group.classList.contains("is-collapsed")) + group.querySelector(".ui-collapsible__title").click(); + const su = [...group.querySelectorAll("button")].find((b) => + b.textContent.startsWith("SU-000001"), + ); + su?.click(); + const add = await until(() => + [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === "자재 추가"), + ); + if (!add) return JSON.stringify({ error: "자재 추가 단추 없음" }); + add.click(); + const box = await until(() => document.querySelector(".m02-pick input")); + let item = null; + for (let tries = 0; tries < 3 && !item; tries += 1) { + // 서버가 막 다시 떴으면 첫 찾기가 늦음 — 다시 침(찾기 칸 글 = 코드) + box.value = tries ? `${CODE} ` : CODE; + box.dispatchEvent(new Event("input", { bubbles: true })); + item = await until(() => { + const items = [...document.querySelectorAll(".m02-pick__item")]; + return items.length === 1 && items[0].dataset.material === CODE ? items[0] : null; + }, 4000); + } + if (!item) return JSON.stringify({ error: "고르기 창에 코드 줄 없음" }); + item.click(); + const row = await until(() => { + const tr = document.querySelector(`tr[data-labor-mat="${CODE}"]`); + return tr && !tr.textContent.includes("…") ? tr : null; + }, 8000); + if (!row) return JSON.stringify({ error: "자재 줄이 안 섬(계산 대기)" }); + const table = row.closest("table"); + const out = { + heads: [...table.querySelectorAll("thead th")].map((th) => th.textContent.trim()), + prices: [...row.querySelectorAll("[data-price-slot]")].map((td) => td.textContent.trim()), + base: [...row.querySelectorAll(".is-base-price")].map((td) => td.dataset.priceSlot), + baseWeight: getComputedStyle(row.querySelector(".is-base-price") ?? row).fontWeight, + qty: { + base: row.querySelector(".m02-labrow__qty .m02-labrow__base")?.textContent.trim(), + applied: row.querySelector(".m02-labrow__qty .m02-labrow__applied")?.textContent.trim(), + }, + money: { + base: row.querySelector(".m02-labrow__money .m02-labrow__base")?.textContent.trim(), + applied: row.querySelector(".m02-labrow__money .m02-labrow__applied")?.textContent.trim(), + }, + }; + // 되돌림 — 그 줄 [빼기](저장 안 함) + [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "빼기")?.click(); + await wait(400); + out.after = document.querySelectorAll(`tr[data-labor-mat="${CODE}"]`).length; + return JSON.stringify(out); +})();