From f7ebf1831fdd727dd5598b721afda6c5134985dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:08:07 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat(721-1):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=C2=B7=20=EC=9E=90=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=E3=80=8C=EA=B8=B0=EB=B3=B8=20=EB=A1=9C=EC=A7=81=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=E3=80=8D=EC=9D=84=20=E3=80=8C=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=E3=80=8D=20=EC=99=BC=EC=AA=BD=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=A4=84=20=EB=8D=94=ED=95=98=EA=B8=B0=E3=80=8D=20?= =?UTF-8?q?=E2=86=92=20=E3=80=8C=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Sangeun_Detail.ts | 32 +++++++++---------- ui_template/ui_template_locale_m2.ts | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 274ab9d44..3bbbde1dc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -5,7 +5,7 @@ * 시험 계산(`Logic_Calc` · 바탕 M01 로직) · 텍스트 수식(바탕 M01 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘) * 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만 * 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) - * 바탕 없는 SK = [M01 로직 고름] → 바탕 · 단위 · 바탕 없이 줄만도 됨 + * 바탕 없는 SK = [기본 로직 선택] → 바탕 · 단위 · 바탕 없이 줄만도 됨 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -96,6 +96,20 @@ export function mountSangeunDetail( }), }); addButton.hidden = !edit; + const pick = createButton({ + label: L("M02_SangeunPickBase"), + variant: "filled", + size: 28, + onClick: () => + openLogicPicker((x) => { + doc.바탕 = { 로직: x.키 }; + delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 + if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; + touch(false); + void loadBase(); + }), + }); + pick.hidden = true; // 보임은 drawHead 가 정함 const baseHint = createText(L("M02_SangeunBaseViews")); host.replaceChildren( el("div", { @@ -104,7 +118,7 @@ export function mountSangeunDetail( children: [ head, notes, - el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }), + el("div", { className: "m02-sk__head", children: [views.root, pick, addButton, baseHint] }), editor, ], }), @@ -164,19 +178,6 @@ export function mountSangeunDetail( const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null; const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판); - const pick = createButton({ - label: L("M02_SangeunPickBase"), - variant: "filled", - size: 28, - onClick: () => - openLogicPicker((x) => { - doc.바탕 = { 로직: x.키 }; - delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 - if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; - touch(false); - void loadBase(); - }), - }); pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length; head.replaceChildren( field(L("M02_Name"), "이름"), @@ -191,7 +192,6 @@ export function mountSangeunDetail( ...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []), ], }), - pick, ); } diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index cd9687885..d7edfbbd0 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -202,8 +202,8 @@ export const ui_locales_m2 = { M02_SangeunRemoved: ["뺀 줄", "Removed rows"], M02_SangeunUnit: ["단위(1 단위 값)", "Unit (value per 1)"], M02_SangeunNeed: ["비우면 저장 안 됨", "Required"], - M02_SangeunPickBase: ["M01 로직 고름", "Pick M01 logic"], - M02_SangeunAddLine: ["줄 더하기", "Add row"], + M02_SangeunPickBase: ["기본 로직 선택", "Select base logic"], + M02_SangeunAddLine: ["항목 추가", "Add item"], M02_SangeunDrop: ["빼기", "Remove"], M02_SangeunRestore: ["되살리기", "Restore"], M02_SangeunWas: ["바탕", "Base"], From 931793d0319608f322ee42b6ad06729754b0e8b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:11:12 +0900 Subject: [PATCH 02/23] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20560=20=C2=B7=20=EB=82=B4=EC=97=AD=2018=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=EA=B8=B0=EB=A1=9D=20=E2=80=94=20=EB=B0=A4?= =?UTF-8?q?=20=EB=A7=A1=EA=B9=80(=EC=9A=94=EA=B5=AC=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=20=EB=8C=80=EC=A1=B0=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=C2=B7=20=EC=95=84=EC=B9=A8=20=EC=95=84?= =?UTF-8?q?=ED=8B=B0=ED=8C=A9=ED=8A=B8=20=EB=B3=B4=EA=B3=A0=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1101/사용자요구_1101.md | 7 +++++++ docs/raw/요구_내역서/사용자요구_내역서.md | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/docs/raw/요구_1101/사용자요구_1101.md b/docs/raw/요구_1101/사용자요구_1101.md index dedd7a3d8..7f151f3f2 100644 --- a/docs/raw/요구_1101/사용자요구_1101.md +++ b/docs/raw/요구_1101/사용자요구_1101.md @@ -133,3 +133,10 @@ M01/M02에 대한 화면 검증은 잠시 미루고 라이다 데이터와 관 ## 요구 559 (11-01 · 사용자 · 요구서 558 물음에 답) 분류 없는 라이다용 내장 CSF 개선 → 처음부터 점진 TIN 내장 + +## 요구 560 (11-01 · 사용자 · 밤 맡김 · 라이다 · 내역서 공통) + +오늘은 나는 쉬러갈 생각임. 너가 브레인으로서 업무 수행할 내용 +1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음. +2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행 +3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유 diff --git a/docs/raw/요구_내역서/사용자요구_내역서.md b/docs/raw/요구_내역서/사용자요구_내역서.md index 2cce3f98e..ea4adb73a 100644 --- a/docs/raw/요구_내역서/사용자요구_내역서.md +++ b/docs/raw/요구_내역서/사용자요구_내역서.md @@ -139,3 +139,7 @@ M02 단가산출/일위대가에서 선택에 대한 검색기능에서 여러 1. 일위대가 수량 → 일위대가의 하나의 단가산출행의 수기입력적보는 기준단위 당 수량이 맞음. 하지만 이값은 구조물 조합의 엑셀에서 알약으로 연결하거나 프로젝트 내부에서 계산된 값과 연결되어져야한다고 내가 말했잖아. 만약 일위대가의 만드는 당시에 계산값을 확인하기 위해 임시 입력을 휘발성으로 입력받으라고도 말했고 이해를 못하는것같네.... 2. M01 창 고침 → M01단가산출의 노임은 변경되지 않는게 맞음. 단, 만약 M02의 단가산출과 일위대가의 상세화면이라면 사용자가 변경할수 있어야하며 이값은 M01의 값을 변경하는게 아니라 M02 변경항목에 별도로 보관되어야하는 값임. 필요한 경우 데이터 일원화를 위해 변경시 M02에 단가산출에서 자동생성도 필요할 수도 있음. + +## 내역 18 (11-01 · 사용자 · 밤 맡김 · 원문 = 사용자요구_1101.md 요구 560 과 같음) + +구현 완료 시 요구사항 반영을 원문으로 다시 읽어 확인 · 그 뒤 브레인이 사용자처럼 브라우저로 검증 · 최종 완료 시 아침에 볼 아티팩트 결과보고서. From 7621fff233bee2ab0a48cde47cf862f4190076b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:11:43 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat(720-1):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=B8=B5=20=EB=8B=A8=EC=B6=94=20=3D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=95=88=20=ED=95=9C=20=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C=20=EB=92=A4=20=EA=B7=B8=20=EC=B8=B5=20?= =?UTF-8?q?=EB=82=98=EB=AC=B4=20+=20=EC=83=81=EC=84=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 낡은 시험 test_m02_precombo_scope 층 단추 줄 글 대조를 새 꼴로(옛 go = 나무만) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts | 16 +++++++++++++--- resources/tester/test_m02_precombo_scope.py | 6 +++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 4ff1e2265..395bb2e8d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -244,8 +244,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise setPre(next), - // 켜진 단추 다시 누름 = 그 층 목록표(생성 · 복사 · 삭제) + // 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표(720-1) · 켜진 단추 다시 누름 = 목록표 + go: (next) => + void main + .confirmLeave() + .then((ok) => ok && (setPre(next), void main.open(precomboList(pre)))), reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))), }); @@ -256,7 +259,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise ((sk = preScope(next)), void side.refreshSangeun()), + go: (next) => + void main + .confirmLeave() + .then( + (ok) => + ok && + ((sk = preScope(next)), void side.refreshSangeun(), void main.open(sangeunList(sk))), + ), reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))), }); const side = buildSide({ diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 921f89074..1ae74c681 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -73,7 +73,11 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움(): assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음 assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음 bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0] - assert "go: (next) => setPre(next)," in bar and "main.open(precomboList(pre))" in bar + # 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표 + go = bar.split("go: (next) =>")[1].split("reselect:")[0] + assert "main.confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go + sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0] + assert "main.confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0] # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 assert page.count('layerBar("') == 1 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page From 0a86b4b0605a182e9df395cc9a0def0d5c8183d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:15:14 +0900 Subject: [PATCH 04/23] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20561=20=EC=9B=90=EB=AC=B8=20=C2=B7=20PLAN?= =?UTF-8?q?=20644(=EA=B8=B0=EC=A1=B4=20=EB=9D=BC=EC=9D=B4=EB=8B=A4=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=98=81=EA=B5=AC?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=C2=B7=20=EC=83=88=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EB=AF=B8=EB=A6=AC=20=EC=A7=80=EC=9D=8C)=20=C2=B7?= =?UTF-8?q?=20723-4(B09=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=95=88?= =?UTF-8?q?=20=EC=A4=84=20=EC=88=98=EB=9F=89=20=EC=97=B0=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1101/사용자요구_1101.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/raw/요구_1101/사용자요구_1101.md b/docs/raw/요구_1101/사용자요구_1101.md index 7f151f3f2..0da7cbad7 100644 --- a/docs/raw/요구_1101/사용자요구_1101.md +++ b/docs/raw/요구_1101/사용자요구_1101.md @@ -140,3 +140,12 @@ M01/M02에 대한 화면 검증은 잠시 미루고 라이다 데이터와 관 1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음. 2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행 3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유 + +## 요구 561 (11-01 · 사용자 · 밤 맡김 다시 · 4 번 더함) + +오늘은 나는 쉬러갈 생각임. 너가 브레인으로서 업무 수행할 내용 + +1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음. +2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행 +3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유 +4. 현재 존재하는 프로젝트들에 사용되던 라이다 데이터를 영구저장소에 복제하여 넣고 내가 프로젝트를 접근하면 바로 확인할 수 있게 반영 From 366fd7a7002f1833b7a2b8568d6e7143bed0bac6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:20:24 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat(640-1):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=8B=A4=20=EA=B8=B0=EB=B3=B8=20=EA=B3=A0=EB=A6=84=20=E2=80=94?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=20implicit=20=C2=B7=20=ED=8F=89=ED=99=9C?= =?UTF-8?q?=20=EB=81=94=20=C2=B7=20=EB=B6=84=EB=A5=98=20=EB=8D=AE=EC=9E=84?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20implicit=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EC=97=90=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확정 기본 표현 implicit · 평활 False · WF1 자동 짓기에 확정 표현 더함(SURFACE_AUTO_METHODS 는 dtm 그대로) - 「분류 있음」 자동 고름 = 비율 0.2 % + 10 m 칸 덮임 50 % · 덮임 저장본(classification_coverage.json · 점군 안 풂) - 분류 LAS 의 implicit 는 기본 필터에만 지음(csf 두 번 안 지음) - B04 화면 확정 없을 때 기본 표현 implicit - 지킴 시험 test_640_lidar_default Co-Authored-By: Claude Opus 5.5 --- B03_FileInput/B03_FileInput_Service_WF1.py | 6 +- B04_PreProcess/B04_PreProcess_Engine.py | 2 +- ...PreProcess_Engine_Filter_Classification.py | 57 ++++++++++++- .../B04_PreProcess_Engine_Ground.py | 6 ++ .../B04_PreProcess_Engine_Pipeline.py | 6 +- B04_PreProcess/B04_PreProcess_UI_Page.ts | 4 +- config/config_system_terrain.py | 10 ++- resources/tester/test_640_lidar_default.py | 81 +++++++++++++++++++ 8 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_640_lidar_default.py diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 65e3d2185..2957df93d 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -24,6 +24,7 @@ from config.config_system import ( SEND_ANALYSIS_COMPLETION_EMAIL, SHEET_SURFACE_DEFAULT_METHOD, SURFACE_AUTO_METHODS, + SURFACE_CONFIRM_DEFAULT_METHOD, SURFACE_CONTOUR_INTERVAL_M, ) @@ -67,7 +68,8 @@ async def trigger_wf1_analysis_and_email( # 자동 전처리는 기본 조합만 만든다 — 필터는 엔진이 입력 LAS를 보고 정하고 # (분류 있으면 classification, 없으면 csf), 표현은 SURFACE_AUTO_METHODS. # 다른 조합은 관리자가 B04 드롭다운에서 요청할 때 만든다 (2026-09-01 사용자 확정). - methods = list(SURFACE_AUTO_METHODS) + # 확정 표현(implicit)도 함께 지음 — 엔진이 기본 필터에만 지음(요구 558). + methods = list(dict.fromkeys([*SURFACE_AUTO_METHODS, SURFACE_CONFIRM_DEFAULT_METHOD])) params = { "input_file_id": str(input_file_id), "source_filters": None, @@ -176,10 +178,10 @@ async def trigger_wf1_analysis_and_email( else: # 자동 확정은 엔진이 정한 기본 필터를 그대로 따른다 — 이제 그 필터 # 하나만 만들어 두므로 config 기본값과 어긋나면 확정할 모델이 없다. + # 표현 · 평활은 config 확정 기본값(implicit · 평활 끔 — 요구 558). selection = surface_confirmation_defaults() if resolved_filters: selection["source_filter"] = resolved_filters[0] - selection["method"] = methods[0] try: model_id = await find_surface_model_for_selection( connection, project_id, selection diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 71f1c0583..becc04c5e 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -279,7 +279,7 @@ def run_surface_analysis( _report(40, "ground_filter", "지면 필터 적용 중") # 자동 전처리는 목록을 넘기지 않는다 — 기본 필터는 입력 LAS를 보고 여기서 정한다. if not source_filters: - source_filters = resolve_auto_source_filters(data) + source_filters = resolve_auto_source_filters(data, processed_dir) logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters)) masks: dict[str, np.ndarray] = {} for filter_key in source_filters: diff --git a/B04_PreProcess/B04_PreProcess_Engine_Filter_Classification.py b/B04_PreProcess/B04_PreProcess_Engine_Filter_Classification.py index b3475e88a..44373f8b3 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Filter_Classification.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Filter_Classification.py @@ -5,11 +5,18 @@ 쓸 수 있는지는 has_classified_ground()로 먼저 판정한다. """ +import json +from pathlib import Path from typing import Any import numpy as np -from config.config_system import SURFACE_CLASSIFIED_GROUND_MIN_RATIO +from common_util.common_util_json import atomic_write_json +from config.config_system import ( + SURFACE_CLASSIFIED_COVERAGE_CELL_M, + SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO, + SURFACE_CLASSIFIED_GROUND_MIN_RATIO, +) # ASPRS 표준 지면 클래스 코드 GROUND_CLASS_CODE = 2 @@ -38,3 +45,51 @@ def has_classified_ground( return False ground = int(np.count_nonzero(classification == GROUND_CLASS_CODE)) return ground / total >= minimum_ratio + + +def classified_coverage( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + cell_meters: float = SURFACE_CLASSIFIED_COVERAGE_CELL_M, +) -> float: + """점 있는 칸 중 class 2 가 있는 칸의 비율 — 일부만 분류된 LAS 를 가려냄.""" + if "classification" not in structured_data: + return 0.0 + classification = np.asarray(structured_data["classification"]) + xyz = structured_data["xyz"] + bounds = np.asarray(structured_data["bounds"], dtype=np.float64) + cell = max(float(cell_meters), 0.1) + cols = int((bounds[0, 1] - bounds[0, 0]) // cell) + 1 + rows = int((bounds[1, 1] - bounds[1, 0]) // cell) + 1 + occupied = np.zeros((rows, cols), dtype=bool) + ground = np.zeros((rows, cols), dtype=bool) + for start in range(0, len(classification), 5_000_000): + points = np.asarray(xyz[start : start + 5_000_000, :2], dtype=np.float64) + gx = np.clip(((points[:, 0] - bounds[0, 0]) // cell).astype(np.int32), 0, cols - 1) + gy = np.clip(((points[:, 1] - bounds[1, 0]) // cell).astype(np.int32), 0, rows - 1) + occupied[gy, gx] = True + is_ground = classification[start : start + len(points)] == GROUND_CLASS_CODE + ground[gy[is_ground], gx[is_ground]] = True + total = int(np.count_nonzero(occupied)) + return float(np.count_nonzero(ground)) / total if total else 0.0 + + +def has_classified_coverage( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + processed_dir: Path | None = None, + minimum_ratio: float = SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO, +) -> bool: + """분류 덮임 문턱 판정 — `processed_dir` 면 structured.npz 지문으로 저장본 재사용.""" + if processed_dir is None: + return classified_coverage(structured_data) >= minimum_ratio + stat = (processed_dir / "structured.npz").stat() + stamp = [int(stat.st_size), int(stat.st_mtime_ns)] + path = processed_dir / "classification_coverage.json" + try: + stored = json.loads(path.read_text(encoding="utf-8")) + coverage = float(stored["coverage"]) if stored.get("structured") == stamp else None + except (OSError, ValueError, KeyError, TypeError, AttributeError): + coverage = None + if coverage is None: + coverage = classified_coverage(structured_data) + atomic_write_json(path, {"structured": stamp, "coverage": coverage}) + return coverage >= minimum_ratio diff --git a/B04_PreProcess/B04_PreProcess_Engine_Ground.py b/B04_PreProcess/B04_PreProcess_Engine_Ground.py index f178a6064..a7021f9e8 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Ground.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Ground.py @@ -5,12 +5,14 @@ source_filters를 따른다. """ +from pathlib import Path from typing import Any import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import ( filter_classification, + has_classified_coverage, has_classified_ground, ) from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf @@ -45,14 +47,18 @@ def usable_conditional_filters( def resolve_auto_source_filters( structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + processed_dir: Path | None = None, ) -> list[str]: """자동 전처리가 만들 필터를 입력 LAS를 보고 정한다. 첫 항목이 기본 확정값이다. 업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장 안정적이다. **csf는 분류가 있어도 함께 만든다** — csf 처리 결과를 눈으로 검증할 수 있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 csf 하나만 남는다. + 「분류 있음」 = 비율 문턱 + 덮임(일부만 분류된 LAS 거름) — 자동 고름에만(요구 558). """ usable = usable_conditional_filters(structured_data) + if usable and not has_classified_coverage(structured_data, processed_dir): + usable = [] return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER] diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index 8fb66c937..98406aaa0 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -190,6 +190,8 @@ def _build_all_terrain_models( methods_list.remove("dtm") methods_list.insert(0, "dtm") methods = tuple(methods_list) + # 무거운 implicit 은 기본 필터(첫 필터)에만 — 분류 LAS 의 csf 까지 두 번 짓지 않음(요구 558). + later_methods = tuple(method for method in methods if method != "implicit") signature = config_signature(config) bounds = np.asarray(structured_data["bounds"], dtype=np.float64) xyz = structured_data["xyz"] @@ -211,7 +213,7 @@ def _build_all_terrain_models( } started_at = time.monotonic() timeout = max(0, int(config.get("sync_timeout_seconds", 0))) - total_units = max(1, len(filters) * len(methods)) + total_units = max(1, len(methods) + (len(filters) - 1) * len(later_methods)) done_units = 0 failures = 0 @@ -229,7 +231,7 @@ def _build_all_terrain_models( ) filter_entry["source_point_count"] = context.source_count - for method in methods: + for method in later_methods if filter_index else methods: stem = f"{method}_{filter_key}" entry = filter_entry["methods"].get(method, {}) diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 360e82fc2..6a525a9ef 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -54,7 +54,7 @@ import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_swi const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const; const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; const DEFAULT_FILTER = "csf"; -const DEFAULT_METHOD = "dtm"; +const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저 const ROUTE_STAGE = ROUTES.B05_PROFILE; // 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다. const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"]; @@ -494,7 +494,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { ]); models = modelResponse.models; // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. - // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. + // 확정 이력이 없을 때만 개발 기본값(csf·implicit)으로 둔다. if (confirmed.model_id) { if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; if (confirmed.method) methodGroup.select.value = confirmed.method; diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 2e83d2fd7..99fc5b1df 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -63,6 +63,11 @@ SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002") ) +# 자동 기본 고름만 — 점 있는 칸(한 변 아래 m) 중 class 2 가 있는 칸이 이 비율 이상이어야 분류 있음. +SURFACE_CLASSIFIED_COVERAGE_CELL_M = float(os.getenv("SURFACE_CLASSIFIED_COVERAGE_CELL_M", "10.0")) +SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO = float( + os.getenv("SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO", "0.5") +) # 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) @@ -166,9 +171,10 @@ SHEET_SURFACE_AUTO_METHODS = [ # 일반 사용자 WF1 자동 확정 기본값 SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") -SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") +# 확정 표현 = implicit · 평활 끔 — WF1 자동 짓기도 이 표현을 기본 필터에 지음(요구 558). +SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "implicit") SURFACE_CONFIRM_DEFAULT_SMOOTH = ( - os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" + os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "False").lower() == "true" ) diff --git a/resources/tester/test_640_lidar_default.py b/resources/tester/test_640_lidar_default.py new file mode 100644 index 000000000..5e58c1042 --- /dev/null +++ b/resources/tester/test_640_lidar_default.py @@ -0,0 +1,81 @@ +"""640 라이다 기본 고름 · implicit 점 수 · 격자 칸 뽑기 · 지문 · 틈 닫기 (요구 558). + +서버 함수만 — 작은 합성 점군 · 임시 폴더. +""" + +from pathlib import Path + +import numpy as np + +from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import ( + classified_coverage, + has_classified_coverage, +) +from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters +from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models +from config import config_system as cfg + + +def _plane(size_m: float = 60.0, spacing_m: float = 0.5) -> np.ndarray: + axis = np.arange(0.0, size_m, spacing_m) + x, y = np.meshgrid(axis, axis) + z = 100.0 + 0.2 * x + 0.1 * np.sin(y / 5.0) + return np.column_stack([x.ravel(), y.ravel(), z.ravel()]) + + +def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict: + data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T} + if classification is not None: + data["classification"] = classification + return data + + +def test_확정_기본값은_implicit_평활_끔() -> None: + assert cfg.SURFACE_CONFIRM_DEFAULT_METHOD == "implicit" + assert cfg.SURFACE_CONFIRM_DEFAULT_SMOOTH is False + assert cfg.SURFACE_AUTO_METHODS == ("dtm",) # D03 자체 라이다는 dtm 만 그대로 + + +def test_일부만_분류된_LAS_는_자동_기본에서_csf() -> None: + points = _plane(100.0) + partial = np.ones(len(points), dtype=np.uint8) + partial[(points[:, 0] < 30) & (np.arange(len(points)) % 4 == 0)] = 2 # 비율 7 % · 덮임 30 % + assert classified_coverage(_structured(points, partial)) < 0.5 + assert resolve_auto_source_filters(_structured(points, partial)) == ["csf"] + + spread = np.ones(len(points), dtype=np.uint8) + spread[::37] = 2 # 비율 2.7 % · 덮임 100 % + assert classified_coverage(_structured(points, spread)) == 1.0 + assert resolve_auto_source_filters(_structured(points, spread)) == ["classification", "csf"] + + +def test_덮임은_저장본을_다시_씀(tmp_path: Path) -> None: + points = _plane(40.0) + classes = np.full(len(points), 2, dtype=np.uint8) + (tmp_path / "structured.npz").write_bytes(b"x") + assert has_classified_coverage(_structured(points, classes), tmp_path) + assert (tmp_path / "classification_coverage.json").is_file() + + class _NoXyz(dict): + def __getitem__(self, key): # type: ignore[no-untyped-def] + assert key != "xyz", "저장본이 맞으면 점군을 풀지 않아야 함" + return dict.__getitem__(self, key) + + assert has_classified_coverage(_NoXyz(_structured(points, classes)), tmp_path) + + +def _config(**override: object) -> dict: + config = cfg.build_surface_model_config() + config.update({"source_filters": ["a", "b"], "precompute": ["dtm", "implicit"]}) + config.update(override) + return config + + +def test_implicit_은_기본_필터에만(tmp_path: Path) -> None: + points = _plane() + data = _structured(points) + masks = {"a": np.ones(len(points), bool), "b": np.arange(len(points)) % 2 == 0} + manifest = build_all_terrain_models(data, masks, tmp_path, _config()) + assert set(manifest["source_filters"]["a"]["methods"]) == {"dtm", "implicit"} + assert set(manifest["source_filters"]["b"]["methods"]) == {"dtm"} + assert not (tmp_path / "implicit_b.npz").exists() From 194e1a9c39d9cf2b8c891f261f23e13e494df83c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:21:29 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat(720-2):=20=EC=B5=9C=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=ED=8F=B4=EB=8D=94=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=A4=84=20=EB=A9=94=EB=89=B4=20+=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=B8=B5=20=EC=A4=84=20=E3=80=8C+=E3=80=8D(?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20addFolder([]))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 조합 나무 「〇〇로 복사…」 몸을 TreePanel_CopyTo 로 뗌(TreePanel 700 → 688 줄 · 동작 같음) - 낡은 시험 test_m02_layer_screen(복사 대상 글 자리 = CopyTo) · test_m02_precombo_scope(층 줄 add 인자) 새 꼴로 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_TreePanel.ts | 28 ++++--------- .../M02_MasterTemplete_TreePanel_CopyTo.ts | 39 ++++++++++++++++++- .../M02_MasterTemplete_UI_Page.ts | 7 +++- .../M02_MasterTemplete_UI_Side_Library.ts | 24 +++++++++--- .../M02_MasterTemplete_UI_Side_Precombo.ts | 5 ++- resources/tester/test_m02_layer_screen.py | 5 ++- resources/tester/test_m02_precombo_scope.py | 6 +-- ui_template/ui_template_locale_m2.ts | 1 + 8 files changed, 80 insertions(+), 35 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index f8fa81004..7a9050f46 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -36,7 +36,7 @@ import { type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; -import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; +import { copyToLayer, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { @@ -196,7 +196,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { { label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) }, ...(opt.copyTo ?? []).map((to) => ({ label: to.label.replace("{n}", n), - run: () => void copyTo(list, to), + run: () => copyTo(list, to), })), ]; }; @@ -243,6 +243,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { ...(isTop ? [] : [ + { label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }, // 720-2 = 「+」 길 { label: L("M02_TreeRename"), run: () => void rename(target) }, { label: L("M02_TreeMove"), run: () => void move(target) }, { label: L("M02_Delete"), run: () => void remove(target) }, @@ -554,25 +555,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); } - /** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */ - async function copyTo(list: Target[], to: CopyTarget): Promise { - if (!api.paste || !list.length) return; + /** 「〇〇로 복사…」(39-6) — 몸은 `TreePanel_CopyTo` */ + function copyTo(list: Target[], to: CopyTarget): void { + const paste_ = api.paste; + if (!paste_ || !list.length) return; const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) }; - try { - const dest = await askCopyFolder(api.fetch, to, list.length); - if (!dest) return; - await api.paste(to.layer, to.owner, clip, dest); - const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); - showToast( - L("M02_TreeCopiedTo") - .replace("{n}", String(list.length)) - .replace("{layer}", where) - .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), - "success", - ); - } catch (error) { - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - } + void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to); } async function paste(dest: string[]): Promise { diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts index ae6ab3e8b..c36404fee 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -2,10 +2,12 @@ * M02_MasterTemplete_TreePanel_CopyTo.ts * 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null. * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. + * `copyToLayer` = 고름 → 붙여넣기 → 알림(구조물 조합 나무 `TreePanel` 에서 뗌 · 720-2). * ========================================================================== */ +import { showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import type { Layer, TreeClip, TreeView } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; @@ -26,3 +28,38 @@ export async function askCopyFolder( const title = to.label.replace("{n}", String(n)); return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder")); } + +/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 부른 나무는 그대로 */ +export async function copyToLayer( + api: { + fetch: (layer: Layer, owner: string | null) => Promise; + paste: ( + layer: Layer, + owner: string | null, + clip: TreeClip, + dest: string[], + ) => Promise; + }, + clip: TreeClip, + to: CopyTarget, +): Promise { + const n = String(clip.refs.length); + try { + const dest = await askCopyFolder(api.fetch, to, clip.refs.length); + if (!dest) return; + await api.paste(to.layer, to.owner, clip, dest); + const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); + showToast( + L("M02_TreeCopiedTo") + .replace("{n}", n) + .replace("{layer}", where) + .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), + "success", + ); + } catch (error) { + showToast( + L("M02_ActionFailed").replace("{value}", error instanceof Error ? error.message : ""), + "error", + ); + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 395bb2e8d..d56440cb8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -239,11 +239,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + // 층 단추 옆 「+」(720-2) = 서고 칸이 만든 맨 위 메뉴 + const preBar = (add: HTMLElement): HTMLElement => createLayerBar({ user, active: { root: pre.root, owner: pre.owner }, company, + add, // 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표(720-1) · 켜진 단추 다시 누름 = 목록표 go: (next) => void main @@ -254,11 +256,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + const skBar = (add: HTMLElement): HTMLElement => createLayerBar({ user, active: { root: sk.root, owner: sk.owner }, company, + add, go: (next) => void main .confirmLeave() diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts index a2180e8f8..483834e96 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -8,10 +8,11 @@ * 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). + * 최상위 폴더(720-2) — 층 단추 옆 「+」(맨 위 메뉴) · 폴더 · 문서 줄 [최상위 폴더 만들기] = 같은 `addFolder([])`. * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { t as L } from "@ui/ui_template_locale"; import { @@ -58,8 +59,8 @@ export interface LibraryScope { export interface LibraryOptions { kind: LibraryKind; scope: () => LibraryScope; - /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ - bar: () => HTMLElement; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 단추 옆 「+」(720-2) */ + bar: (add: HTMLElement) => HTMLElement; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; @@ -130,6 +131,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { className: "ui-tree m02-tree__body", attrs: { "data-library-tree": kind, tabindex: "0" }, }); + // 층 단추 옆 「+」(720-2 · 구조물 조합 꼴) — 맨 위 메뉴 · 읽기만 층 = 숨김(그릴 때) + const add = createButton({ + label: "+", + variant: "ghost", + onClick: (event) => openTreeMenu(event, menuAt([])), + }); + add.classList.add("m02-tree__add"); + add.title = L("M02_TreeAddTop"); + add.setAttribute("aria-label", L("M02_TreeAddTop")); const leaves = new Map(); const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 const targets = new Map(); @@ -255,6 +265,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { item(opt.text.newHere, () => make(path)), ...(path.length ? [ + item(L("M02_TreeAddTopFolder"), () => void addFolder([])), item(L("M02_TreeRename"), () => void rename(path)), item(L("M02_Delete"), () => void remove([target])), { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, @@ -288,12 +299,14 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; const target: Target = { kind: "item", item }; targets.set(id, target); + const off = (): string | undefined => (opt.scope().canWrite ? undefined : tc("ReadOnly")); const single = (): TreeMenuItem[] => [ + { label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]), disabledReason: off() }, { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, { label: L("M02_Delete"), run: () => void remove([target]), - disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), + disabledReason: off(), }, ...(opt.more?.(row.이름, label) ?? []), ]; @@ -424,7 +437,8 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { const refresh = async (keep = false): Promise => { const mine = ++seq; const at = opt.scope(); - barHost.replaceChildren(opt.bar()); + add.hidden = !at.canWrite; + barHost.replaceChildren(opt.bar(add)); if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); const [got, tree] = await Promise.all([ // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index b8a79ae16..c97bf1697 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -15,6 +15,7 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main"; import { buildLibrarySection, type LibraryHandle, + type LibraryOptions, type LibraryScope, } from "./M02_MasterTemplete_UI_Side_Library"; @@ -53,8 +54,8 @@ export const precomboList = (scope: PrecomboScope): Selection => ({ export interface PrecomboOptions { scope: () => PrecomboScope; - /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ - bar: () => HTMLElement; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 「+」(720-2) */ + bar: LibraryOptions["bar"]; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index a74df5040..8e58b5371 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -296,9 +296,10 @@ def test_켜진_층_단추는_목록으로_복사_끌기(): assert "(to === layer && toOwner === here)) return [];" in page copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts") assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to - assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text( + assert "const dest = await askCopyFolder(api.fetch, to, clip.refs.length);" in copy_to + assert "void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);" in _text( M02 / "M02_MasterTemplete_TreePanel.ts" - ) + ) # 720-2 — 몸은 CopyTo 로 뗌(TreePanel 700 줄) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 1ae74c681..660859a49 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -72,12 +72,12 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움(): assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음 assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음 - bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0] + bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split("const side = buildSide(")[0] # 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표 go = bar.split("go: (next) =>")[1].split("reselect:")[0] - assert "main.confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go + assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0] - assert "main.confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0] + assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0] # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 assert page.count('layerBar("') == 1 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index cd9687885..7a008cc83 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -107,6 +107,7 @@ export const ui_locales_m2 = { M02_TreeAddTop: ["맨 위에 폴더 · 항목 추가", "Add folder or item at top"], M02_TreeEmpty: ["항목이 없음", "No items"], M02_TreeAddFolder: ["폴더 추가", "Add folder"], + M02_TreeAddTopFolder: ["최상위 폴더 만들기", "New top-level folder"], M02_TreeAddItem: ["항목 추가", "Add item"], M02_TreeRename: ["이름 바꾸기", "Rename"], M02_TreeMove: ["옮기기", "Move"], From f912efd36af07c6dfbe388eb8f4296d66c5db071 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:21:57 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat(641-1):=20implicit=20=EA=B5=B3?= =?UTF-8?q?=ED=9E=8C=20=EA=B2=A9=EC=9E=90=20=E2=80=94=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EC=A7=80=ED=91=9C=EB=A9=B4=20B05=20=C2=B7=20B06=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implicit_{필터}_grid.npz = 같은 필터 dtm 축 · 유효 칸 · 유효 칸만 RBF · 밖 = dtm 값 · implicit · dtm 보다 옛것이면 다시 셈(파생 저장본) - 서버 표본기 implicit = 굳힌 격자 DtmGridSampler(매번 RBF 다시 짓기 없앰) · Node 측점이 source 로 같은 격자 - B05 브라우저 격자 통로 · B06 서버 재계산 격자 = implicit 확정이면 같은 굳힌 격자(새 짝 없음 · 기존 heightAt 짝) - 짓기 끝(build_implicit)에서 한 번 굳힘 · 못 지으면 처음 읽을 때 Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Engine_ModelBuild.py | 4 ++ .../B05_Profile_Engine_Sections_Node.py | 2 +- B05_Profile/B05_Profile_Router_Terrain.py | 41 ++++++++---- .../B06_Section_Server_Calc_Prebuild.py | 11 ++-- common_util/common_util_surface_sampler.py | 66 ++++++++++++++----- 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py index baa33c69f..77870b49c 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py @@ -4,6 +4,7 @@ TerrainContext에서 파생 격자·샘플을 받아 각 표현의 모델(npz) (GLB/PLY)를 저장하고 메타데이터를 반환한다. """ +from contextlib import suppress from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import ( write_binary_ply, write_glb, ) +from common_util.common_util_surface_sampler import implicit_grid_npz def build_tin( @@ -209,6 +211,8 @@ def build_implicit( smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32), ) write_glb(preview_path, vertices, faces, context.bounds) + with suppress(OSError, ValueError): # 641-1 굳힌 격자 — 못 지으면 처음 읽을 때 지음 + implicit_grid_npz(output_dir, stem.removeprefix("implicit_")) progress(100) return with_footprint( context, diff --git a/B05_Profile/B05_Profile_Engine_Sections_Node.py b/B05_Profile/B05_Profile_Engine_Sections_Node.py index 9e86ddb11..c01965948 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Node.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Node.py @@ -7,7 +7,7 @@ (화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source · coordinate_reference · cad_exchange · options)만 붙임. -DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) · +DTM · implicit(굳힌 격자 641-1) 지표면만(격자 이진 = `dtm_grid_file`) — 그 밖(tin · nurbs · meshfree) · Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김). 측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`). """ diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index d796d0d20..df650bf6b 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -40,7 +40,11 @@ from common_util.common_util_surface_confirmation import ( design_terrain, get_surface_confirmation_params, ) -from common_util.common_util_surface_sampler import build_surface_sampler, dtm_grid_file +from common_util.common_util_surface_sampler import ( + build_surface_sampler, + dtm_grid_file, + implicit_grid_npz, +) from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -161,15 +165,17 @@ async def choosable_terrains( async def _design_model( connection: aiomysql.Connection, project_id: UUID, params: dict ) -> tuple[int, str, str] | None: - """설계 지형 DTM 모델 (id, 표현, 파일 경로) — 서버 표본기처럼 stage 1 params 의 `dtm_{filter}.npz` - (확정 행 먼저). 확정 행이 params 와 어긋나도 서버와 같은 바닥을 받게(578-1) · 없으면 None.""" - name = f"dtm_{params.get('source_filter') or ''}.npz" + """설계 지형 격자 모델 (id, 표현, 파일 경로) — 서버 표본기처럼 stage 1 params 의 + `{dtm|implicit}_{filter}.npz`(확정 행 먼저 · implicit = 굳힌 격자 641-1). 확정 행이 params 와 + 어긋나도 서버와 같은 바닥을 받게(578-1) · 없으면 None.""" + method = "implicit" if params.get("method") == "implicit" else "dtm" + name = f"{method}_{params.get('source_filter') or ''}.npz" async with connection.cursor() as cursor: await cursor.execute( "SELECT id, model_type, model_file_path FROM surface_models" - " WHERE project_id = %s AND model_type = 'dtm' AND status IN ('CONFIRMED', 'COMPLETE')" + " WHERE project_id = %s AND model_type = %s AND status IN ('CONFIRMED', 'COMPLETE')" " ORDER BY status = 'CONFIRMED' DESC, created_at DESC", - (str(project_id),), + (str(project_id), method), ) rows = await cursor.fetchall() row = next((r for r in rows if Path(str(r[2] or "")).name == name), None) @@ -181,15 +187,16 @@ def _design_grid( ) -> dict | None: """설계 지형 격자 받을 자리(PLAN 344-1) — 서버 표본기(`build_surface_sampler`)와 같은 파일만. - DTM 아닌 표현(TIN 등)은 None → 화면은 서버 표본 길 그대로. `signature` = manifest 구성 · - 평활 서명 — 화면이 주소에 붙여 지형이 바뀐 뒤 옛 격자를 먼저 내지 않음. + DTM · implicit(굳힌 격자) 밖 표현(TIN 등)은 None → 화면은 서버 표본 길 그대로. `signature` = + manifest 구성 · 평활 서명 — 화면이 주소에 붙여 지형이 바뀐 뒤 옛 격자를 먼저 내지 않음. """ source_filter = str(params.get("source_filter") or "") - if not confirmed or params.get("method") != "dtm" or confirmed[1] != "dtm": + method = params.get("method") + if not confirmed or method not in ("dtm", "implicit") or confirmed[1] != method: return None - if Path(confirmed[2]).name != f"dtm_{source_filter}.npz": + if Path(confirmed[2]).name != f"{method}_{source_filter}.npz": return None - smooth = bool(params.get("smooth")) + smooth = bool(params.get("smooth")) and method == "dtm" try: manifest = json.loads((models_dir / "manifest.json").read_text(encoding="utf-8")) except (OSError, ValueError): @@ -206,7 +213,8 @@ def _design_grid( async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool) -> Path: - """그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) — 없으면 LookupError(까닭). + """그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) · implicit 모델 = 굳힌 격자 + (641-1 · 표본기와 같은 파일) — 없으면 LookupError(까닭). 경로 = 프로젝트 storage 안 DB 상대 경로만(밖으로 나가면 LookupError · 계산 창구 `/calc` 도 씀). """ @@ -218,12 +226,19 @@ async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool (model_id, str(project_id)), ) row = await cursor.fetchone() - if not stored or not row or row[0] != "dtm" or not row[1]: + if not stored or not row or row[0] not in ("dtm", "implicit") or not row[1]: raise LookupError("DTM 격자가 없는 지표면 모델입니다.") root = Path(resolve_stored_project_path(stored)) model_path = (root / str(row[1])).resolve() if not model_path.is_relative_to(root.resolve()): raise LookupError("DTM 격자가 없는 지표면 모델입니다.") + if row[0] == "implicit": + try: + return await asyncio.to_thread( + implicit_grid_npz, model_path.parent, model_path.stem.removeprefix("implicit_") + ) + except (OSError, ValueError) as exc: + raise LookupError("implicit 격자를 굳히지 못했습니다.") from exc smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz") npz_path = smooth_path if smooth and smooth_path.is_file() else model_path if not npz_path.is_file(): diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 34798e2e8..49dc3aa6c 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -255,12 +255,13 @@ def _enforce_stored_designs( def design_grid_file(project_root: Path, params: dict[str, Any] | None) -> Path | None: - """설계 지형이 DTM 이면 격자 이진(`dtm_grid_file` · 표본기 `build_surface_sampler` 와 - 같은 npz) · DTM 아닌 지표면(tin · nurbs · …) · 못 엶 = None(파이썬 길).""" - from common_util.common_util_surface_sampler import dtm_grid_file + """설계 지형이 DTM · implicit 면 격자 이진(`dtm_grid_file` · 표본기 `build_surface_sampler` 와 + 같은 npz · implicit = 굳힌 격자 641-1) · 그 밖 지표면(tin · nurbs · …) · 못 엶 = None + (파이썬 길).""" + from common_util.common_util_surface_sampler import dtm_grid_file, implicit_grid_npz params = params or {} - if params.get("method") != "dtm" or not params.get("source_filter"): + if params.get("method") not in ("dtm", "implicit") or not params.get("source_filter"): return None models = project_root / "B04_PreProcess" / "models" smooth = models / f"dtm_{params['source_filter']}_smooth.npz" @@ -270,6 +271,8 @@ def design_grid_file(project_root: Path, params: dict[str, Any] | None) -> Path else smooth.with_name(f"dtm_{params['source_filter']}.npz") ) try: + if params["method"] == "implicit": + npz = implicit_grid_npz(models, str(params["source_filter"])) return dtm_grid_file(npz) if npz.is_file() else None except (OSError, ValueError, KeyError) as exc: logger.warning("서버 재계산: DTM 격자 이진을 못 만듦 — 파이썬 길 · %s", exc) diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index 32d86f06f..22febb9d2 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -177,6 +177,55 @@ def dtm_grid_file(npz_path: Path | str) -> Path: return target +def implicit_rbf(path: Path | str) -> Callable[[np.ndarray], np.ndarray]: + """`implicit_{필터}.npz`(중심점 · 높이 · smoothing) → RBF 표고 함수((N, 2) → (N,)).""" + from scipy.interpolate import RBFInterpolator + + with np.load(Path(path), allow_pickle=False) as data: + centers = np.asarray(data["centers_xy"], dtype=np.float64) + center_z = np.asarray(data["center_z"], dtype=np.float64) + smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0 + return RBFInterpolator( + centers, + center_z, + neighbors=min(64, len(centers)), + smoothing=smoothing, + kernel="thin_plate_spline", + ) + + +def implicit_grid_npz(models_dir: Path | str, source_filter: str) -> Path: + """implicit 를 1 m 격자로 굳힌 파생 저장본 `implicit_{필터}_grid.npz`(641-1 · 요구 558-3). + + 축 · 유효 칸 = 같은 필터 `dtm_{필터}.npz` 그대로 · 유효 칸만 RBF · 밖은 dtm 값(NaN 없음). + 없거나 implicit · dtm 보다 옛것이면 다시 셈 — 서버 표본 · 브라우저 · Node 측점 · B06 이 같은 값. + """ + check_surface_keys(source_filter, "implicit") + models_dir = Path(models_dir) + source = models_dir / f"implicit_{source_filter}.npz" + dtm = models_dir / f"dtm_{source_filter}.npz" + target = models_dir / f"implicit_{source_filter}_grid.npz" + newest = max(source.stat().st_mtime_ns, dtm.stat().st_mtime_ns) + if target.is_file() and target.stat().st_mtime_ns >= newest: + return target + with np.load(dtm, allow_pickle=False) as data: + x, y = data["x"], data["y"] + z = np.array(data["z"]) + valid = np.asarray(data["valid_mask"], dtype=bool) + resolution = data["resolution"] if "resolution" in data else np.array([1.0], np.float32) + rbf = implicit_rbf(source) + rows, cols = np.nonzero(valid) + query = np.column_stack([x[cols], y[rows]]).astype(np.float64) + for start in range(0, len(query), 50_000): + end = start + 50_000 + z[rows[start:end], cols[start:end]] = rbf(query[start:end]) + temporary = target.with_name(f"{target.stem}.{os.getpid()}.tmp") + with open(temporary, "wb") as handle: + np.savez(handle, x=x, y=y, z=z, valid_mask=valid, resolution=resolution) + os.replace(temporary, target) + return target + + @dataclass class CallableSurfaceSampler: """테스트와 어댑터에 사용할 함수 기반 sampler.""" @@ -255,21 +304,8 @@ def build_surface_sampler( return InterpolatedSurfaceSampler(lambda xy: spline.ev(xy[:, 1], xy[:, 0]), footprint) if method == "implicit": - from scipy.interpolate import RBFInterpolator - - path = models_dir / f"implicit_{source_filter}.npz" - with np.load(path, allow_pickle=False) as data: - centers = np.asarray(data["centers_xy"], dtype=np.float64) - center_z = np.asarray(data["center_z"], dtype=np.float64) - smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0 - interpolator = RBFInterpolator( - centers, - center_z, - neighbors=min(64, len(centers)), - smoothing=smoothing, - kernel="thin_plate_spline", - ) - return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint) + # 굳힌 격자(641-1) — 브라우저 · Node 측점 · B06 이 같은 파일을 읽음(`source` 가 붙음) + return DtmGridSampler.open_cached(implicit_grid_npz(models_dir, source_filter)) if method == "meshfree": from scipy.interpolate import LinearNDInterpolator From b3e62c7ae4bdff81c4140c63bb186dc617b498d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:22:09 +0900 Subject: [PATCH 08/23] =?UTF-8?q?feat(641-2):=20B07=20=EC=9C=A0=EC=97=AD?= =?UTF-8?q?=20=EC=9D=8C=EC=98=81=20=C2=B7=20D03=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=E2=80=94=20implicit=20=ED=99=95=EC=A0=95=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=8F=84=20=ED=8F=89=ED=99=9C=20DTM=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=ED=82=B4=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽는 자리 한 줄씩(계산 코드 그대로) · dtm 확정은 전과 같음 - test_641_1_implicit_grid: 굳힌 격자 = RBF · 다시 셈 · 서버 · Node · B06 · 브라우저 같은 파일 · B07 · D03 전후 Co-Authored-By: Claude Opus 5.5 --- .../B07_DesignDetail_Router_Support_Basin.py | 2 +- .../D03_PlanAnalysis_TerrainSource.py | 3 +- resources/tester/test_641_1_implicit_grid.py | 163 ++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_641_1_implicit_grid.py diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 9062e89d2..472aeb8e2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -476,7 +476,7 @@ def _hillshade_for_box( params = getattr(context, "surface_params", None) or {} source_filter = str(params.get("source_filter") or "csf") - smooth = bool(params.get("smooth", True)) + smooth = bool(params.get("smooth", True)) or params.get("method") == "implicit" # 641-2 models_dir = Path(context.project_root) / "B04_PreProcess" / "models" candidates = [models_dir / f"dtm_{source_filter}_smooth.npz"] if smooth else [] candidates.append(models_dir / f"dtm_{source_filter}.npz") diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py b/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py index 079100b32..a06d095b8 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py @@ -105,7 +105,8 @@ def road_dem(models: Path, params: dict[str, Any], source: str) -> tuple[str, Pa return source, None npz = Path(models) / f"dtm_{picked.get('source_filter') or ''}.npz" smooth = npz.with_name(f"{npz.stem}_smooth.npz") - npz = smooth if picked.get("smooth") and smooth.is_file() else npz + implicit = picked.get("method") == "implicit" # 641-2 — implicit 확정이어도 평활 DTM + npz = smooth if (picked.get("smooth") or implicit) and smooth.is_file() else npz return got, npz if npz.is_file() else None diff --git a/resources/tester/test_641_1_implicit_grid.py b/resources/tester/test_641_1_implicit_grid.py new file mode 100644 index 000000000..1609f5a59 --- /dev/null +++ b/resources/tester/test_641_1_implicit_grid.py @@ -0,0 +1,163 @@ +"""PLAN 641 — implicit 굳힌 격자(`implicit_{필터}_grid.npz`) · 화면 일치 · B07 · D03 그대로. + +641-1: 굳힌 격자 축 · 유효 = 같은 필터 dtm · 유효 칸 값 = RBF 값 · 밖 = dtm 값 · 옛것이면 다시 셈 · +서버 표본기 · Node 측점(`source`) · B06 재계산 · 브라우저 격자 통로가 같은 파일. +641-2: implicit 확정이어도 B07 음영 · D03 지형은 평활 DTM · dtm 확정은 전과 같음. +""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import asynccontextmanager +from pathlib import Path + +import numpy as np + +from B05_Profile import B05_Profile_Router_Terrain as terrain_router +from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild +from common_util.common_util_surface_sampler import ( + DtmGridSampler, + build_surface_sampler, + dtm_grid_bytes, + implicit_grid_npz, + implicit_rbf, +) +from D03_PlanAnalysis.D03_PlanAnalysis_TerrainSource import road_dem + +IMPLICIT = {"source_filter": "classification", "method": "implicit", "smooth": False} + + +def _surface(x, y): + return 100.0 + 0.2 * x + 0.1 * y + 2.0 * np.sin(x / 7.0) * np.cos(y / 5.0) + + +def _project(tmp_path: Path) -> Path: + models = tmp_path / "B04_PreProcess" / "models" + models.mkdir(parents=True) + axis = np.arange(0.0, 40.0, dtype=np.float32) + valid = np.zeros((40, 40), dtype=bool) + valid[5:35, 3:30] = True + for name, z in (("dtm_classification", 50.0), ("dtm_classification_smooth", 60.0)): + np.savez( + models / f"{name}.npz", + x=axis, + y=axis, + z=np.full((40, 40), z, np.float32), + valid_mask=valid, + resolution=np.array([1.0], np.float32), + ) + rng = np.random.default_rng(641) + centers = rng.uniform(0.0, 39.0, (600, 2)).astype(np.float32) + np.savez( + models / "implicit_classification.npz", + centers_xy=centers, + center_z=_surface(centers[:, 0], centers[:, 1]).astype(np.float32), + smoothing=np.array([0.1], np.float32), + ) + return models + + +def test_굳힌_격자_축_유효_dtm_같음_값_RBF(tmp_path): + models = _project(tmp_path) + path = implicit_grid_npz(models, "classification") + assert path.name == "implicit_classification_grid.npz" + with np.load(path) as grid, np.load(models / "dtm_classification.npz") as dtm: + assert np.array_equal(grid["x"], dtm["x"]) and np.array_equal(grid["y"], dtm["y"]) + assert np.array_equal(grid["valid_mask"], dtm["valid_mask"]) + z, valid = grid["z"], grid["valid_mask"] + assert not np.isnan(z).any() + assert np.array_equal(z[~valid], dtm["z"][~valid]) # 밖 = dtm 값 + rows, cols = np.nonzero(valid) + query = np.column_stack([grid["x"][cols], grid["y"][rows]]).astype(np.float64) + rbf = implicit_rbf(models / "implicit_classification.npz") + assert np.abs(rbf(query) - z[rows, cols]).max() < 1e-4 # 유효 칸 = RBF(float32) + + +def test_옛것이면_다시_셈_같으면_그대로(tmp_path): + models = _project(tmp_path) + path = implicit_grid_npz(models, "classification") + stamp = path.stat().st_mtime_ns + assert implicit_grid_npz(models, "classification").stat().st_mtime_ns == stamp + source = models / "implicit_classification.npz" + os.utime(source, ns=(stamp + 10**9, stamp + 10**9)) + assert implicit_grid_npz(models, "classification").stat().st_mtime_ns > stamp + + +def test_서버_Node_B06_브라우저_같은_파일(tmp_path, monkeypatch): + models = _project(tmp_path) + sampler = build_surface_sampler(models, "classification", "implicit", False) + grid_npz = models / "implicit_classification_grid.npz" + assert isinstance(sampler, DtmGridSampler) + assert Path(sampler.source) == grid_npz.resolve() # Node 측점 = dtm_grid_file(source) + + # 서버 표본 = 굳힌 격자 표본(같은 측점 같은 표고) + points = np.array([[10.3, 12.7], [20.0, 20.0], [1.0, 1.0]]) + z, valid = sampler.sample_xy(points) + z2, valid2 = DtmGridSampler.from_npz(grid_npz).sample_xy(points) + assert np.array_equal(valid, valid2) and np.allclose(z[valid], z2[valid2], atol=0) + assert valid.tolist() == [True, True, False] + + # B06 서버 재계산 격자 = 같은 npz 의 `.bin` + bin_path = prebuild.design_grid_file(tmp_path, IMPLICIT) + assert bin_path is not None and bin_path.read_bytes() == dtm_grid_bytes(grid_npz) + + # 브라우저 격자 통로 — implicit 모델 행 → 굳힌 격자 + row = ("implicit", "B04_PreProcess/models/implicit_classification.npz") + + class _Cursor: + async def execute(self, *_args): + return None + + async def fetchone(self): + return row + + class _Connection: + @asynccontextmanager + async def cursor(self): + yield _Cursor() + + async def stored(_connection, _project_id): + return "x" + + monkeypatch.setattr(terrain_router, "get_project_storage_relative_path", stored) + monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: tmp_path) + served = asyncio.run(terrain_router.dtm_grid_npz(_Connection(), "p", 9, False)) + assert served.resolve() == grid_npz.resolve() + + info = terrain_router._design_grid( + IMPLICIT, models, (9, "implicit", "B04_PreProcess/models/implicit_classification.npz") + ) + assert info is not None and info["model_id"] == 9 and info["smooth"] is False + assert ( + terrain_router._design_grid(IMPLICIT, models, (3, "dtm", "x/dtm_classification.npz")) + is None + ) + + +def test_D03_B07_implicit_확정이면_평활_DTM(tmp_path): + models = _project(tmp_path) + assert road_dem(models, IMPLICIT, "design")[1].name == "dtm_classification_smooth.npz" + dtm = {**IMPLICIT, "method": "dtm"} + assert road_dem(models, dtm, "design")[1].name == "dtm_classification.npz" # 전과 같음 + assert road_dem(models, {**dtm, "smooth": True}, "design")[1].name.endswith("_smooth.npz") + + +def test_B07_음영_implicit_확정이면_평활_DTM(tmp_path, monkeypatch): + from types import SimpleNamespace + + from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin + + _project(tmp_path) + opened: list[str] = [] + real_load = np.load + monkeypatch.setattr( + np, "load", lambda path, **kw: opened.append(Path(path).name) or real_load(path, **kw) + ) + box = (0.0, 0.0, 39.0, 39.0) + for params, name in ( + (IMPLICIT, "dtm_classification_smooth.npz"), + ({**IMPLICIT, "method": "dtm"}, "dtm_classification.npz"), # 전과 같음 + ): + basin._hillshade_for_box(SimpleNamespace(surface_params=params, project_root=tmp_path), box) + assert opened[-1] == name From 437333c7af158e4859db4c234cbb54ffba3e9b16 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:25:08 +0900 Subject: [PATCH 09/23] =?UTF-8?q?feat(640-2):=20implicit=20=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=20=3D=20=EC=A7=80=EB=A9=B4=EC=A0=90=20=EB=B9=84?= =?UTF-8?q?=EC=9C=A8=20=C2=B7=20=EA=B2=A9=EC=9E=90=20=EC=B9=B8=20=EB=BD=91?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=84=A4=EC=A0=95=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=EB=B0=94=EB=80=8C=EB=A9=B4=20=EB=8B=A4=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SURFACE_IMPLICIT_POINT_RATIO 0.175 · _MIN_POINTS 5 만 · _MAX_POINTS 200 만(옛 _MAX_POINTS_PER_TILE 걷음) - TerrainContext.grid_sample — 칸 = √(외곽 넓이 / 점 수) · 씨앗 558 · 칸마다 하나 · 모자라면 무작위 채움(sample() 그대로) - 짓기 캐시 흠 — manifest 항목 완료일 때만 재사용(지문 바뀐 뒤 implicit · nurbs · meshfree 옛 파일 재사용 막음) - 지킴 시험 test_640_lidar_default 점 수 · 격자 뽑기 · 지문 다시 지음 Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Engine_ModelBuild.py | 12 +++- .../B04_PreProcess_Engine_ModelContext.py | 26 +++++++++ .../B04_PreProcess_Engine_Pipeline.py | 24 +++----- config/config_system_terrain.py | 11 ++-- resources/tester/test_640_lidar_default.py | 55 +++++++++++++++++++ 5 files changed, 105 insertions(+), 23 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py index baa33c69f..f80d73b91 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py @@ -168,11 +168,19 @@ def build_nurbs( ) +def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int: + """implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부.""" + minimum = int(config["implicit_min_points"]) + if ground_count <= minimum: + return int(ground_count) + ratio = round(ground_count * float(config["implicit_point_ratio"])) + return int(min(max(ratio, minimum), int(config["implicit_max_points"]))) + + def build_implicit( context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback ) -> dict[str, Any]: - maximum = max(100, int(context.config["implicit_max_points_per_tile"])) - points = context.sample(maximum) + points = context.grid_sample(implicit_point_count(context.source_count, context.config)) unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True) points = points[unique_indices] if len(points) < 4: diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py b/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py index 13300da3c..a5efb1ca3 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py @@ -29,6 +29,8 @@ ProgressCallback = Callable[[int], None] # 대용량 포인트 배치 처리 크기 _BATCH_SIZE = 500_000 +# implicit 격자 칸 뽑기 씨앗 — 고정이라 다시 지어도 같은 중심점 +IMPLICIT_SAMPLE_SEED = 558 # NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²). # 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은 @@ -262,6 +264,30 @@ class TerrainContext: self._samples[maximum] = points return points + def grid_sample(self, count: int, seed: int = IMPLICIT_SAMPLE_SEED) -> np.ndarray: + """implicit 전용 — 격자 칸(√(외곽 넓이 / 점 수))마다 하나 · 모자라면 무작위로 채움. + + 파일 차례 뽑기는 점 많은 트인 곳에 몰려 숲 밑 성긴 곳이 빔(요구 558). 씨앗 고정. + """ + indices = self.indices() + if len(indices) <= count: + return np.asarray(self.xyz[indices], dtype=np.float32) + _, _, resolution, footprint = self.footprint() + cell = max(math.sqrt(float(footprint.sum()) * resolution * resolution / count), 0.01) + xy = np.asarray(self.xyz[indices][:, :2], dtype=np.float64) + rng = np.random.default_rng(seed) + order = rng.permutation(len(indices)) + gx = ((xy[order, 0] - self.bounds[0, 0]) // cell).astype(np.int64) + gy = ((xy[order, 1] - self.bounds[1, 0]) // cell).astype(np.int64) + _, first = np.unique(gy * (int(gx.max()) + 1) + gx, return_index=True) + chosen = order[first] + if len(chosen) > count: + chosen = rng.choice(chosen, count, replace=False) + elif len(chosen) < count: + rest = np.setdiff1d(np.arange(len(indices)), chosen, assume_unique=True) + chosen = np.concatenate([chosen, rng.choice(rest, count - len(chosen), replace=False)]) + return np.asarray(self.xyz[indices[np.sort(chosen)]], dtype=np.float32) + def footprint(self) -> tuple[float, float, float, np.ndarray]: if self._footprint is not None: return self._footprint diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index 98406aaa0..f2f8766bf 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -151,7 +151,13 @@ _REPRESENTATIONS = { def _cache_is_valid( output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any] ) -> bool: - """디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다.""" + """디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다. + + manifest 항목이 완료여야 함 — 설정 지문이 바뀌면 manifest 가 비므로 파일만 남은 + 옛 결과(평활 없는 implicit · nurbs · meshfree)를 다시 짓는다(요구 558). + """ + if entry.get("status") != "completed": + return False ext = "ply" if method == "meshfree" else "glb" files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and ( output_dir / f"{stem}.npz" @@ -236,22 +242,6 @@ def _build_all_terrain_models( entry = filter_entry["methods"].get(method, {}) if not force and _cache_is_valid(output_dir, stem, method, entry, config): - if entry.get("status") != "completed": - entry.update( - { - "status": "completed", - "representation": _REPRESENTATIONS[method], - "model_file": f"{stem}.npz", - "preview_file": f"{stem}_preview." - + ("ply" if method == "meshfree" else "glb"), - "preview_media_type": "application/octet-stream" - if method == "meshfree" - else "model/gltf-binary", - "error": None, - } - ) - filter_entry["methods"][method] = entry - _write_json_file(manifest_path, manifest) interval = float(config.get("contour_interval_meters", 1.0)) contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" needs_contours = not contour_path.exists() diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 99fc5b1df..e28ab794b 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -116,9 +116,10 @@ SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0 SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") ) -SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( - os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") -) +# implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부(요구 558). +SURFACE_IMPLICIT_POINT_RATIO = float(os.getenv("SURFACE_IMPLICIT_POINT_RATIO", "0.175")) +SURFACE_IMPLICIT_MIN_POINTS = int(os.getenv("SURFACE_IMPLICIT_MIN_POINTS", "50000")) +SURFACE_IMPLICIT_MAX_POINTS = int(os.getenv("SURFACE_IMPLICIT_MAX_POINTS", "2000000")) SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) @@ -196,7 +197,9 @@ def build_surface_model_config() -> dict: "nurbs_degree": SURFACE_NURBS_DEGREE, "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, - "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, + "implicit_point_ratio": SURFACE_IMPLICIT_POINT_RATIO, + "implicit_min_points": SURFACE_IMPLICIT_MIN_POINTS, + "implicit_max_points": SURFACE_IMPLICIT_MAX_POINTS, "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, diff --git a/resources/tester/test_640_lidar_default.py b/resources/tester/test_640_lidar_default.py index 5e58c1042..f92ee1b75 100644 --- a/resources/tester/test_640_lidar_default.py +++ b/resources/tester/test_640_lidar_default.py @@ -12,6 +12,8 @@ from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import ( has_classified_coverage, ) from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters +from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import implicit_point_count +from B04_PreProcess.B04_PreProcess_Engine_ModelContext import TerrainContext from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models from config import config_system as cfg @@ -79,3 +81,56 @@ def test_implicit_은_기본_필터에만(tmp_path: Path) -> None: assert set(manifest["source_filters"]["a"]["methods"]) == {"dtm", "implicit"} assert set(manifest["source_filters"]["b"]["methods"]) == {"dtm"} assert not (tmp_path / "implicit_b.npz").exists() + + +def test_implicit_점_수_비율_하한_상한() -> None: + config = cfg.build_surface_model_config() + assert (config["implicit_point_ratio"], config["implicit_min_points"]) == (0.175, 50_000) + assert config["implicit_max_points"] == 2_000_000 + assert implicit_point_count(1_140_716, config) == 199_625 # 용화 분류 + assert implicit_point_count(768_273, config) == 134_448 # 용화 CSF + assert implicit_point_count(100_000, config) == 50_000 # 하한 + assert implicit_point_count(30_000, config) == 30_000 # 하한보다 적으면 전부 + assert implicit_point_count(50_000_000, config) == 2_000_000 # 상한 + + +def test_격자_칸_뽑기는_고르고_씨앗_고정() -> None: + rng = np.random.default_rng(1) + dense = rng.uniform(0, 20, (200_000, 2)) # 왼쪽 아래 트인 곳 — 점 몰림 + sparse = rng.uniform(0, 100, (30_000, 2)) # 전체 성긴 숲 밑 + xy = np.vstack([dense, sparse]) + points = np.column_stack([xy, np.zeros(len(xy))]) + context = TerrainContext( + xyz=points, + mask=np.ones(len(points), bool), + bounds=np.array([points.min(axis=0), points.max(axis=0)]).T, + config=cfg.build_surface_model_config(), + ) + picked = context.grid_sample(5_000) + assert len(picked) == 5_000 and len(np.unique(picked[:, :2], axis=0)) == 5_000 + assert np.array_equal(picked, context.grid_sample(5_000)) + in_dense = np.count_nonzero((picked[:, 0] < 20) & (picked[:, 1] < 20)) / len(picked) + file_order = context.sample(5_000) + in_dense_file = np.count_nonzero((file_order[:, 0] < 20) & (file_order[:, 1] < 20)) + assert in_dense < 0.1 < 0.5 < in_dense_file / len(file_order) # 파일 차례는 몰림 · 격자는 고름 + assert len(context.grid_sample(300_000)) == len(points) # 모자라면 전부 + + +def test_설정_지문이_바뀌면_implicit_다시_지음(tmp_path: Path) -> None: + points = _plane() + data = _structured(points) + masks = {"a": np.ones(len(points), bool)} + config = _config( + source_filters=["a"], + precompute=["implicit"], + implicit_min_points=500, + implicit_max_points=1_000, + ) + build_all_terrain_models(data, masks, tmp_path, config) + with np.load(tmp_path / "implicit_a.npz") as old: + assert len(old["center_z"]) == 1_000 + changed = dict(config, implicit_max_points=2_000) + manifest = build_all_terrain_models(data, masks, tmp_path, changed) + with np.load(tmp_path / "implicit_a.npz") as new: + assert len(new["center_z"]) == 2_000 + assert manifest["source_filters"]["a"]["methods"]["implicit"]["center_count"] == 2_000 From a0c2db507f587b12082e7f5fe22f8cafddb7340d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:25:12 +0900 Subject: [PATCH 10/23] =?UTF-8?q?feat(720-3):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=82=98=EB=AC=B4=20=EB=AC=B8=EC=84=9C=20=EC=A4=84?= =?UTF-8?q?=20[=EC=9D=B4=EB=A6=84=20=EB=B0=94=EA=BE=B8=EA=B8=B0]=20?= =?UTF-8?q?=E2=80=94=20=EC=9D=BD=EA=B3=A0=20=EC=9D=B4=EB=A6=84=EB=A7=8C=20?= =?UTF-8?q?=EA=B0=88=EC=95=84=20=EC=A0=80=EC=9E=A5=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=92=A4=20=EC=83=81=EC=84=B8=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=20=EC=A0=9C=EB=AA=A9=20=EC=83=88=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열린 문서면 저장 안 한 고침 물음 뒤 새 이름으로 다시 엶 · 서버 새 길 없음 - 새 지킴 시험 test_720_m02_folder_layer(임시 storage 이름 바꾸기 왕복 · 화면 글 대조) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_UI_Main.ts | 4 + .../M02_MasterTemplete_UI_Side_Library.ts | 32 ++++++++ resources/tester/test_720_m02_folder_layer.py | 76 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 resources/tester/test_720_m02_folder_layer.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8a9182d22..33105a4ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -595,6 +595,10 @@ export function createMain( sheet.recalc(); panel.refresh(sheet.getDoc()?.열 ?? []); } + // 720-3 — 서고 문서(단가산출 · 일위대가) 이름을 고쳐 저장하면 머리 제목도 새 이름(「키 이름」) + const named = (doc as { 이름?: unknown } | null)?.이름; + if ((at.kind === "sangeun" || isPrecombo(at.kind)) && typeof named === "string" && sel === at) + title.textContent = at.label = named ? `${at.name} ${named}` : at.name; showNotice([]); showToast(L("M02_Saved"), "success"); onSaved?.(); // 좌측 목록의 판을 새 판으로 diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts index 483834e96..5bdbad680 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -9,6 +9,7 @@ * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). * 최상위 폴더(720-2) — 층 단추 옆 「+」(맨 위 메뉴) · 폴더 · 문서 줄 [최상위 폴더 만들기] = 같은 `addFolder([])`. + * 문서 줄 [이름 바꾸기](720-3) — 읽고 문서 `이름` 만 갈아 저장(서버 새 길 없음) · 열린 문서면 새 이름으로 다시 엶. * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; @@ -29,6 +30,8 @@ import { listTemplates, newLibrary, pasteLibraryTree, + readTemplate, + saveTemplate, type Layer, type LibraryKind, type PrecomboTree, @@ -219,6 +222,34 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); }; + /** 문서 이름 바꾸기(720-3) — 열린 문서면 저장 안 한 고침 물음 → 읽고 `이름` 만 갈아 저장 → 나무 · 상세 새 이름 */ + const renameDoc = async (row: LibraryRow): Promise => { + const at = opt.scope(); + const was = row.제목 || row.이름; + const got = await askText(L("M02_TreeRename"), [ + { key: "name", label: L("M02_TreeItemName"), value: was }, + ]); + const name = got?.name.trim(); + if (!name || name === was) return; + const cur = opt.getOpen(); + const shown = + cur?.kind === kind && + cur.name === row.이름 && + cur.layer === at.layer && + (cur.owner ?? null) === at.owner; + if (shown && !(await opt.confirmLeave())) return; + try { + const doc = await readTemplate(at.layer, kind, row.이름, null, at.owner); + const body = { ...(doc.문서 as Record), 이름: name }; + await saveTemplate(at.layer, kind, row.이름, null, doc.판 ?? "", body, at.owner); + showToast(L("M02_TreeDone"), "success"); + await refresh(true); + if (shown) await open({ ...row, 제목: name }); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + }; + /* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */ const pickedTargets = (): Target[] => [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); @@ -302,6 +333,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { const off = (): string | undefined => (opt.scope().canWrite ? undefined : tc("ReadOnly")); const single = (): TreeMenuItem[] => [ { label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]), disabledReason: off() }, + { label: L("M02_TreeRename"), run: () => void renameDoc(row), disabledReason: off() }, { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, { label: L("M02_Delete"), diff --git a/resources/tester/test_720_m02_folder_layer.py b/resources/tester/test_720_m02_folder_layer.py new file mode 100644 index 000000000..a3b9fddbc --- /dev/null +++ b/resources/tester/test_720_m02_folder_layer.py @@ -0,0 +1,76 @@ +"""M02 폴더 · 층 · 단가산출 이름(PLAN 720 · 내역 16) — 임시 storage + 화면 글 대조. + +720-1 단가산출 · 일위대가 층 단추 = 저장 안 한 고침 물음 → 그 층 목록표 · +720-2 최상위 폴더 = 메뉴 줄 · 층 줄 「+」 같은 `addFolder([])` · +720-3 문서 [이름 바꾸기] = 읽고 `이름` 만 갈아 저장(층 목록 제목 새 이름 · 다른 칸 그대로) · +720-4 구조물 조합 층 = 제자리 바꿈(해시 이동 없음). +""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from resources.tester.test_m02_precombo_folders import client # noqa: F401 — 같은 임시 층 + +M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete" +ITEM = "/api/m02/layers/personal/templates/{}/{}" + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def _rows(c: TestClient, kind: str) -> dict[str, dict]: + rows = c.get("/api/m02/layers/personal/templates").json()["양식"] + return {r["이름"]: r for r in rows if r["종류"] == kind} + + +def _rename(c: TestClient, kind: str, key: str, name: str) -> None: + """화면 `renameDoc` 과 같은 길 — 읽고 `이름` 만 갈아 같은 판으로 저장""" + got = c.get(ITEM.format(kind, key)).json() + body = {"판": got.get("판") or "", "문서": {**got["문서"], "이름": name}} + res = c.put(ITEM.format(kind, key), json=body) + assert res.status_code == 200, res.text + + +def test_일위대가_이름_바꾸기는_이름만(client: TestClient): # noqa: F811 + before = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"] + _rename(client, "precombo", "SC-000002", "나 고침") + after = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"] + assert after["이름"] == "나 고침" + assert {k: v for k, v in after.items() if k != "이름"} == { + k: v for k, v in before.items() if k != "이름" + } + rows = _rows(client, "precombo") + assert rows["SC-000002"]["제목"] == "나 고침" and rows["SC-000001"]["제목"] == "가" + + +def test_단가산출_이름_바꾸기_목록_제목(client: TestClient): # noqa: F811 + body = {"문서": {"이름": "갑", "단위": "㎡", "바탕": None, "더한줄": []}} + res = client.post("/api/m02/layers/personal/library/sangeun", json=body) + assert res.status_code == 200, res.text + key = res.json()["이름"] + _rename(client, "sangeun", key, "을") + assert _rows(client, "sangeun")[key]["제목"] == "을" + assert client.get(ITEM.format("sangeun", key)).json()["문서"]["단위"] == "㎡" + + +def test_화면_층_단추_최상위_폴더_이름_바꾸기(): + page = _text("M02_MasterTemplete_UI_Page.ts") + lib = _text("M02_MasterTemplete_UI_Side_Library.ts") + # 720-1 — 다른 층 단추 = 물음 뒤 목록표 + assert "setPre(next), void main.open(precomboList(pre))" in page + assert "void main.open(sangeunList(sk))" in page.split("const skBar")[1].split("reselect:")[0] + # 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([]) + assert page.count(" add,\n") == 2 + assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib + assert "add.hidden = !at.canWrite;" in lib and "opt.bar(add)" in lib + assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄 + assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([])' in lib # 문서 줄 + tree = _text("M02_MasterTemplete_TreePanel.ts") + assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }' in tree + # 720-3 — 문서 줄 [이름 바꾸기] · 저장 뒤 머리 제목 + assert "run: () => void renameDoc(row)" in lib + assert "이름: name }" in lib and "saveTemplate(at.layer, kind, row.이름, null" in lib + main = _text("M02_MasterTemplete_UI_Main.ts") + assert "title.textContent = at.label = named ? `${at.name} ${named}` : at.name;" in main From b85add0a5159337ccd3006d83b51ee03edd95990 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:27:24 +0900 Subject: [PATCH 11/23] =?UTF-8?q?feat(640-3):=20=EC=99=B8=EA=B3=BD=20?= =?UTF-8?q?=ED=8B=88=20=EB=8B=AB=EA=B8=B0=203=20m=20=E2=80=94=20=EC=84=B1?= =?UTF-8?q?=EA=B8=B4=20=EC=88=B2=20=EB=9D=A0=EA=B9=8C=EC=A7=80=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=20=EB=B2=94=EC=9C=84=20=EC=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SURFACE_FOOTPRINT_GAP_CLOSE_M 1.0 → 3.0 · 「가장 큰 덩이만」 그대로 - 설정 지문에 들어가 다음 짓기 때 모든 표현 · 등고선 · 유효 범위가 새 외곽으로 다시 지어짐(기존 확정 파일 · DB 는 그대로) - 지킴 시험 test_640_lidar_default 틈 닫기 Co-Authored-By: Claude Opus 5.5 --- config/config_system_terrain.py | 3 ++- resources/tester/test_640_lidar_default.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index e28ab794b..7f2a8dfde 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -100,7 +100,8 @@ SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_S # footprint(외곽) 산출 SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) -SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) +# 외곽 틈 닫기 3 m — DJI 지면점이 성긴 숲 띠까지 스캔 범위로 살림(요구 558). +SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "3.0")) SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) SURFACE_KEEP_LARGEST_FOOTPRINT = ( os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" diff --git a/resources/tester/test_640_lidar_default.py b/resources/tester/test_640_lidar_default.py index f92ee1b75..6c2a776ef 100644 --- a/resources/tester/test_640_lidar_default.py +++ b/resources/tester/test_640_lidar_default.py @@ -134,3 +134,22 @@ def test_설정_지문이_바뀌면_implicit_다시_지음(tmp_path: Path) -> No with np.load(tmp_path / "implicit_a.npz") as new: assert len(new["center_z"]) == 2_000 assert manifest["source_filters"]["a"]["methods"]["implicit"]["center_count"] == 2_000 + + +def test_외곽_틈_닫기_3m_는_성긴_띠를_살림() -> None: + points = _plane(60.0) + gap = (points[:, 0] > 28) & (points[:, 0] < 33) # 5 m 빈 띠 — 1 m 닫기면 두 덩이 + points = points[~gap] + + def area(close_m: float) -> float: + config = dict(cfg.build_surface_model_config(), footprint_gap_close_meters=close_m) + context = TerrainContext( + xyz=points, + mask=np.ones(len(points), bool), + bounds=np.array([points.min(axis=0), points.max(axis=0)]).T, + config=config, + ) + return context.footprint_metadata()["footprint_area_m2"] + + assert cfg.SURFACE_FOOTPRINT_GAP_CLOSE_M == 3.0 + assert area(3.0) > 1.8 * area(1.0) # 한 덩이(띠 포함) 대 큰 덩이 하나 From ace439ad04057aadd652e0a503e275bbc49f5487 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:37:10 +0900 Subject: [PATCH 12/23] =?UTF-8?q?feat(720-4):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=B8=B5=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=3D=20=EC=A0=9C=EC=9E=90=EB=A6=AC=20=EB=B0=94=EA=BF=88(?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=EB=A1=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=86=B5=EC=A7=B8=20=EB=8B=A4=EC=8B=9C=20=EC=84=B8=EC=9B=80=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C)=20=C2=B7=20=EB=8F=84=EB=A9=B4=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=C2=B7=20=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B9=B8=20=3D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EB=A9=B4=20=EB=8A=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지: 층 고름(resolve) · 층 맞춤(settleLayer) · 띠 · 나무 · 층 줄 갈아 끼움(build) 한 길 · 주소 replaceState · 새 층 구조물 표 - 편집 칸 권한 = 함수(UI_Main isAdmin) · 「새 항목」 = 고른 것 권한으로 가림 · 불러오기 장부 = 열 때 층 · 권한(Hopyo_Side) - 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로 - 낡은 시험(층 단추 해시 이동 · 사람 나무 · 도면 칸 층 묶음 · 새 항목 · 머리 고침 · 불러오기 권한 · 실무문서 차례 · home) 새 꼴로 — test_m02_practice 도면 차례 글은 이미 낡아 실패하던 것 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Hopyo_Side.ts | 9 +- .../M02_MasterTemplete_UI_Main.ts | 11 +- .../M02_MasterTemplete_UI_Page.ts | 237 ++++++++++-------- .../M02_MasterTemplete_UI_Side.ts | 49 ++-- resources/tester/test_720_m02_folder_layer.py | 22 +- resources/tester/test_m02_combo_screen.py | 2 +- resources/tester/test_m02_combo_table.py | 4 +- resources/tester/test_m02_hopyo_screen.py | 6 +- resources/tester/test_m02_layer_screen.py | 25 +- resources/tester/test_m02_practice.py | 4 +- resources/tester/test_m02_precombo_scope.py | 12 +- resources/tester/test_m02_structure_panel.py | 2 +- 12 files changed, 221 insertions(+), 162 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index ff2948e83..cd670718c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -13,8 +13,8 @@ import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplet import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard"; export interface HopyoSectionOptions { - /** 이 층 쓰기 권한 — 별칭 장부 고침 */ - canWrite: boolean; + /** 이 층 쓰기 권한 — 별칭 장부 고침(구조물 조합 층이 제자리에서 바뀌어 열 때 읽음 · 720-4) */ + canWrite: () => boolean; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; /** 메인 칸에 화면을 띄움 */ @@ -34,7 +34,6 @@ const WIZARD = "불러오기"; export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { const title = L("M02_HopyoImport"); - const scope = pageScope(); const start = createButton({ label: "불러오기", variant: "filled", @@ -63,7 +62,9 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { if (open?.key === key && open.view.isConnected) return; if (!(await opt.confirmLeave())) return; const view = - key === WIZARD ? buildHopyoWizard() : buildHopyoLedger({ scope, canWrite: opt.canWrite }); + key === WIZARD + ? buildHopyoWizard() + : buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() }); opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view); opt.onOpen(); open = { key, view }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 33105a4ce..d68699dba 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -130,9 +130,10 @@ export function newItemButton(run: () => void, label = L("M02_TreeNewItem")): HT return button; } -/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) */ +/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) · + * `isAdmin` = 지금 구조물 조합 층 고칠 권한(층을 제자리에서 바꾸므로 함수 · 720-4) */ export function createMain( - isAdmin: boolean, + isAdmin: () => boolean, onSaved?: () => void, onOpened?: () => void, extras: MainExtras = {}, @@ -185,7 +186,7 @@ export function createMain( // 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만 const showAside = (): void => { const combo = isCombo(sel); - if (extras.newItem) extras.newItem.hidden = !combo; + if (extras.newItem) extras.newItem.hidden = !combo || !sel || !writable(sel); title.classList.toggle("m02-main__title--fold", combo); title.title = combo ? L("M02_TableFold") : ""; if (!combo) { @@ -251,7 +252,7 @@ export function createMain( host.replaceChildren(createEmpty({ text })); }; /** 고를 것마다 고칠 권한 — 사전조합 층은 고름에 실림(134-3) · 그 밖 페이지 층 */ - const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin); + const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin()); const bar = (): void => { reload.hidden = !sel; save.hidden = !sel || !writable(sel); @@ -282,7 +283,7 @@ export function createMain( /** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드 */ function buildPanel(layer: Layer): StructurePanelHandle { const made = createStructurePanel({ - isAdmin, + isAdmin: isAdmin(), confirmLeave, keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림 onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index d56440cb8..24f9d606c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -4,7 +4,8 @@ * 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 나무는 좌측 패널 · PLAN 29-2) * * 한 화면에 층 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 층 = 진입 해시 `?open=&root=&owner=&company=` - * (`M02_MasterTemplete_Layer.ts`) · 층 단추 · 「사람 고르기」를 누르면 해시를 바꿔 이 화면을 다시 세움. + * (`M02_MasterTemplete_Layer.ts`) · 구조물 조합 층 단추 · 사람 나무를 누르면 제자리에서 그 층으로 갈아 끼움 + * (720-4 · 해시는 replaceState · 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로). * 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `accessFor`). * ========================================================================== */ @@ -20,7 +21,7 @@ import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { mountSettingsDrawer } from "../A00_Common/m_settings_nav"; -import { navigateTo, type MountedPage } from "../A00_Common/router"; +import type { MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; import { @@ -33,7 +34,7 @@ import { ownOwner, redirectOldOptions, viewCompany, - type OpenTarget, + type ManageLayer, type Root, } from "./M02_MasterTemplete_Layer"; import { @@ -67,100 +68,141 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => {} }; const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터) const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사) - let layer = layerOfRoot(entry.root); - // 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내) - let owner = entry.owner ?? (entry.root === "company" ? companyOwner(company, user) : null); - if (owner === ownOwner(layer, user)) owner = null; - // 시스템 관리자가 남의 회사 「개인」 — 그 회사 관리자처럼 사람 나무(PLAN 29-6) · 사람을 안 골랐으면 첫 사람 · - // 사람이 없으면 마스터 - const people = entry.root === "personal" && seesOthers(company, user); - if (people && !owner) { - owner = (await fetchOwners("personal", company).catch(() => []))[0]?.owner ?? null; - entry.owner = owner; - if (!owner) [entry.root, layer] = ["master", "system"]; - } - if (accessFor(layer, owner, user).needCompany) { - [entry.root, layer, owner] = ["master", "system", null]; - } - setActiveLayer(layer, owner); - const isAdmin = accessFor(layer, owner, user).canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」) - // 자기 회사용을 읽기만 볼 때 — 안내 + [개인용 복사] - const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null; - // 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래 + // 구조물 조합 층(720-4) — 층 단추 · 사람 나무 · 공유 승인 띠가 바꿈 · 페이지는 그대로 두고 제자리에서 갈아 끼움 + // (`switchStructure` · 해시는 replaceState) · 단가산출 · 일위대가 층 · 왼쪽 칸 펼침은 그대로 + let layer: ManageLayer = "system"; + let owner: string | null = null; + let people = false; + let isAdmin = false; // 이 층을 고칠 수 있나(하위 부품의 「관리자」) + let readOnlyBar: ReturnType | null = null; + let share: ReturnType; + let tree: ReturnType; + let turn = 0; + /** 고른 층 → 실제 층 — 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 · + * 시스템 관리자가 남의 회사 「개인」 = 사람 나무(29-6 · 안 골랐으면 첫 사람 · 사람이 없으면 마스터) · + * 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내) */ + const resolve = async (want: Root, wantOwner: string | null) => { + let [at, lay] = [want, layerOfRoot(want)]; + let who = wantOwner ?? (want === "company" ? companyOwner(company, user) : null); + if (who === ownOwner(lay, user)) who = null; + const many = want === "personal" && seesOthers(company, user); + if (many && !who) { + who = (await fetchOwners("personal", company).catch(() => []))[0]?.owner ?? null; + if (!who) [at, lay] = ["master", "system"]; + } + if (accessFor(lay, who, user).needCompany) [at, lay, who] = ["master", "system", null]; + return { root: at, layer: lay, owner: who, people: many && at === "personal" }; + }; // 「〇〇로 복사…」(30-2 · 39-6) — 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 보는 층은 뺌) · // 대상 폴더는 고르는 창 - const here = owner === ownOwner(layer, user) ? null : owner; - const copyTo = (["master", "company", "personal"] as Root[]).flatMap((one) => { - const to = layerOfRoot(one); - const toOwner = one === "company" ? companyOwner(company, user) : null; - if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; - const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; - return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; - }); - // 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠 - const share = createShare({ - user, - company, - layer, - owner, - showView: (label, node) => - void main.confirmLeave().then((ok) => ok && main.showView(label, node)), - openPrecombo: (r) => - void main.open({ - layer: "personal", - owner: r.주인, - canWrite: false, - projectId: null, - kind: "precombo", - name: r.키, - label: r.이름, - }), - openItem: (r) => void go("personal", r.주인, "구조물조합"), - changed: () => void Promise.all([side.refresh(), tree.refresh()]), - }); - const tree = createTreePanel({ - layer, - owner, - copyTo, - more: share.itemMenu, - canEdit: canEdit(layer, isAdmin), - onPick: (item) => void main.openTable(layer, item.열), - settle: () => main.settle(), - // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 - onChanged: async (colId) => { - await main.openTable(layer, colId, true); - }, - }); - const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { - // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 - if (!(await main.confirmLeave())) return; - navigateTo( - ROUTES.M02_MASTER_TEMPLATE, - entryQuery({ ...entry, open, root: next, owner: nextOwner }), + const copyTargets = () => { + const here = owner === ownOwner(layer, user) ? null : owner; + return (["master", "company", "personal"] as Root[]).flatMap((one) => { + const to = layerOfRoot(one); + const toOwner = one === "company" ? companyOwner(company, user) : null; + if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; + const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; + return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; + }); + }; + /** 지금 층의 띠 · 나무 · 층 줄을 새로 만들어 감싼 칸에 끼움(처음 · 층 바꿈 한 길) */ + const build = (): void => { + // 자기 회사용을 읽기만 볼 때 — 안내 + [개인용 복사] + readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null; + // 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠 + share = createShare({ + user, + company, + layer, + owner, + showView: (label, node) => + void main.confirmLeave().then((ok) => ok && main.showView(label, node)), + openPrecombo: (r) => + void main.open({ + layer: "personal", + owner: r.주인, + canWrite: false, + projectId: null, + kind: "precombo", + name: r.키, + label: r.이름, + }), + openItem: (r) => void go("personal", r.주인), + changed: () => void Promise.all([side.refresh(), tree.refresh()]), + }); + // 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래 + tree = createTreePanel({ + layer, + owner, + copyTo: copyTargets(), + more: share.itemMenu, + canEdit: canEdit(layer, isAdmin), + onPick: (item) => void main.openTable(layer, item.열), + settle: () => main.settle(), + // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + onChanged: async (colId) => { + await main.openTable(layer, colId, true); + }, + }); + topHost.replaceChildren(...(readOnlyBar ? [readOnlyBar.root] : []), share.top); + treeHost.replaceChildren( + layerBar(tree.add, () => void main.openTable(layer)), + peopleTree(tree.root), ); + void tree.refresh(); + }; + /** 층을 정해 페이지 층 길 · 권한을 맞춤 */ + const settleLayer = (got: Awaited>): void => { + [entry.root, entry.owner, layer, owner, people] = [ + got.root, + got.owner, + got.layer, + got.owner, + got.people, + ]; + setActiveLayer(layer, owner); + isAdmin = accessFor(layer, owner, user).canWrite; + }; + /** 구조물 조합 층 바꿈(720-4) — 제자리 · 주소 replaceState · 새 층 구조물 표 */ + const switchStructure = async (want: Root, wantOwner: string | null): Promise => { + const mine = ++turn; + const got = await resolve(want, wantOwner); + if (mine !== turn) return; + settleLayer(got); + const query = new URLSearchParams(entryQuery({ ...entry, open: "구조물조합" })); + history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); + build(); + await main.openTable(layer, null, true); + }; + const go = async (next: Root, nextOwner: string | null): Promise => { + // 저장 안 한 고침은 먼저 물음(취소 = 층 그대로) + if (!(await main.confirmLeave())) return; + await switchStructure(next, nextOwner); }; // 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌 // 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6) // 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표) - const layerBar = (open: OpenTarget, add: HTMLElement | null, reselect: () => void): HTMLElement => + const layerBar = (add: HTMLElement, reselect: () => void): HTMLElement => createLayerBar({ user, active: { root: entry.root, owner }, company, - go: (next, nextOwner) => void go(next, nextOwner, open), - ...(add ? { add } : {}), + go: (next, nextOwner) => void go(next, nextOwner), + add, reselect, }); // 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6) - const peopleTree = (open: OpenTarget, panel: HTMLElement): HTMLElement => + const peopleTree = (panel: HTMLElement): HTMLElement => people && owner && company ? createPeopleTree({ company, active: owner, panel, - go: (next) => void go("personal", next, open), + go: (next) => void go("personal", next), }) : panel; + const topHost = el("div"); + const treeHost = el("div"); // 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 · 열린 표(행렬)를 다시(페이지가 내려가면 듣기 끝) const afterItem = async (): Promise => { await Promise.all([side.refresh(), tree.refresh()]); @@ -169,29 +211,27 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise - tree.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem); + main.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem); document.addEventListener(ITEM_BUILT, onItem); const main = createMain( - isAdmin, + () => isAdmin, () => void Promise.all([side.refresh(), tree.refresh()]), () => side.paint(), { - top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }), - // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) - ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}), + top: topHost, + // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만(편집 칸이 가림) · [새로 만들기] [복사 생성](39-5) + newItem: newItemButton(() => void openNew()), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); }, - // 표 머리 이름 칸 고침 = 나무 이름 바꾸기(31-1) · 쓸 수 있는 층만 - ...(canEdit(layer, isAdmin) - ? { - onHeadRename: (col: StructureColumn, level: number, name: string) => - void tree.editFromTable(headRenameEdit(col, level, name), col.id), - } - : {}), + // 표 머리 이름 칸 고침 = 나무 이름 바꾸기(31-1) · 쓸 수 있는 층만(나무가 가림) + onHeadRename: (col: StructureColumn, level: number, name: string) => + void tree.editFromTable(headRenameEdit(col, level, name), col.id), }, ); + settleLayer(await resolve(entry.root, entry.owner ?? null)); + build(); /** 새 구조물 조합(39-5 · 답 2) — 새로 만들기 = 나무 「항목 추가」 창 · 복사 생성 = 탭 + 목록 → * 지금 보는 층 · 나무에서 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 새 키 · 새 번호 */ const openNew = async (): Promise => { @@ -214,12 +254,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise ({ layer, projectId: null, kind: "table", name: STRUCTURE_TABLE }); // 일위대가 사전조합 층(134-3) — 페이지 층(해시 root)과 따로 · 마스터 · 볼 회사 · 내 개인(사람 나무 없음) · // 못 가는 층(회사 없음) = 마스터 · 고른 층 = 주소 `pre`(페이지를 다시 세우지 않음 · replaceState) const preScope = (want: Root): PrecomboScope => { @@ -273,16 +308,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))), }); const side = buildSide({ - layer, - isAdmin, - onOpen: (sel) => void main.open(sel ?? home), + isSystem: user?.role === "SYSTEM_ADMIN", + canWrite: () => canEdit(layer, isAdmin), + onOpen: (sel) => void main.open(sel ?? home()), getOpen: main.current, confirmLeave: main.confirmLeave, showView: main.showView, - tree: [ - layerBar("구조물조합", tree.add, () => void main.openTable(layer)), - peopleTree("구조물조합", tree.root), - ], + tree: [treeHost], precombo: { scope: () => pre, bar: preBar, @@ -307,12 +339,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => setActiveLayer("system") }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 5a86c8d5a..53f3955cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -4,6 +4,7 @@ * 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. * 구조물 옵션 정의는 M01 로 옮김(716-1). + * 도면 양식 · 실무문서 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. @@ -34,7 +35,7 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side"; -import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo"; import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun"; import { createEmpty } from "@ui/ui_template_empty"; @@ -54,10 +55,10 @@ const STUB_DRAWING = { }; export interface SideOptions { - /** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */ - layer: Layer; - /** 이 층을 고칠 수 있나 */ - isAdmin: boolean; + /** 시스템 관리자 — 도면 양식(시스템 층에만 있음) · 실무문서 칸을 늘 보임(720-4) */ + isSystem: boolean; + /** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */ + canWrite: () => boolean; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ @@ -97,7 +98,7 @@ const failed = (error: unknown): void => showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); export function buildSide(opt: SideOptions): SideHandle { - const layer = opt.layer; + const layer: Layer = "system"; // 도면 양식 목록 · 고름의 층 let items: TemplateInfo[] = []; let seq = 0; @@ -112,7 +113,7 @@ export function buildSide(opt: SideOptions): SideHandle { /** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */ const editButtons = new Map(); const actionRow = (kind: Kind): HTMLElement[] => { - if (!canEdit(layer, opt.isAdmin)) return []; + if (!opt.isSystem) return []; const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" }); const copy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" }); const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" }); @@ -160,23 +161,21 @@ export function buildSide(opt: SideOptions): SideHandle { ? void Promise.all([precombo.refresh(), sangeun.refresh()]) : document.removeEventListener(HOPYO_BUILT, onBuilt); document.addEventListener(HOPYO_BUILT, onBuilt); - // 도면 양식은 시스템 층(마스터 템플릿)에만 - const drawing = - layer === "system" - ? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")) - : null; - // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 - const practice = - layer === "system" && opt.isAdmin - ? buildPracticeSection({ - confirmLeave: opt.confirmLeave, - show: opt.showView, - onOpen: paint, - }) - : null; + // 도면 양식은 시스템 층(마스터 템플릿)에만 — 시스템 관리자면 늘(720-4) + const drawing = opt.isSystem + ? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")) + : null; + // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 시스템 관리자만 + const practice = opt.isSystem + ? buildPracticeSection({ + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: paint, + }) + : null; // 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 const hopyo = buildHopyoSection({ - canWrite: canEdit(layer, opt.isAdmin), + canWrite: opt.canWrite, confirmLeave: opt.confirmLeave, show: opt.showView, onOpen: () => (practice?.clear(), paint()), @@ -197,10 +196,10 @@ export function buildSide(opt: SideOptions): SideHandle { const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); - /** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐 */ + /** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐(도면 양식 = 시스템 층뿐 · 구조물 조합 표 = 어느 층이든) */ const isPicked = (kind: Kind, name: string): boolean => { const cur = opt.getOpen(); - return !!cur && cur.layer === layer && cur.kind === kind && cur.name === name; + return !!cur && cur.kind === kind && cur.name === name; }; const isOpen = (info: TemplateInfo): boolean => isPicked(info.종류, info.이름); @@ -260,7 +259,7 @@ export function buildSide(opt: SideOptions): SideHandle { const mine = ++seq; paintButtons(); try { - items = await listTemplates(layer, null); + items = opt.isSystem ? await listTemplates(layer, null) : []; // 도면 양식 목록 — 시스템 관리자만 } catch (error) { items = []; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); diff --git a/resources/tester/test_720_m02_folder_layer.py b/resources/tester/test_720_m02_folder_layer.py index a3b9fddbc..d22012be0 100644 --- a/resources/tester/test_720_m02_folder_layer.py +++ b/resources/tester/test_720_m02_folder_layer.py @@ -62,7 +62,7 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기(): assert "setPre(next), void main.open(precomboList(pre))" in page assert "void main.open(sangeunList(sk))" in page.split("const skBar")[1].split("reselect:")[0] # 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([]) - assert page.count(" add,\n") == 2 + assert page.count(" add,\n") == 3 # 구조물 조합 · 일위대가 · 단가산출 assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib assert "add.hidden = !at.canWrite;" in lib and "opt.bar(add)" in lib assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄 @@ -74,3 +74,23 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기(): assert "이름: name }" in lib and "saveTemplate(at.layer, kind, row.이름, null" in lib main = _text("M02_MasterTemplete_UI_Main.ts") assert "title.textContent = at.label = named ? `${at.name} ${named}` : at.name;" in main + + +def test_구조물_조합_층_제자리_바꿈(): + """720-4 — 해시 이동 없음 · replaceState · 나무 · 층 줄 · 띠를 감싼 칸에 갈아 끼움 · 권한 = 함수 · + 도면 양식 · 실무문서 = 시스템 관리자면 늘 · 불러오기 장부 = 열 때 층 · 권한.""" + page = _text("M02_MasterTemplete_UI_Page.ts") + assert "navigateTo" not in page + switch = page.split("const switchStructure = async")[1].split("const go =")[0] + assert "history.replaceState(" in switch and "build();" in switch + assert "await main.openTable(layer, null, true);" in switch + assert "if (mine !== turn) return;" in switch # 늦게 온 옛 고름 버림 + build = page.split("const build = (): void => {")[1].split("const settleLayer")[0] + assert "topHost.replaceChildren(" in build and "treeHost.replaceChildren(" in build + assert "tree: [treeHost]," in page and "top: topHost," in page + assert "() => isAdmin," in page and 'isSystem: user?.role === "SYSTEM_ADMIN",' in page + assert "main.root.isConnected ? void afterItem()" in page # 나무를 갈아도 듣기 그대로 + side = _text("M02_MasterTemplete_UI_Side.ts") + assert "const drawing = opt.isSystem" in side and "const practice = opt.isSystem" in side + hopyo = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py index dcf3d396d..4d10e8016 100644 --- a/resources/tester/test_m02_combo_screen.py +++ b/resources/tester/test_m02_combo_screen.py @@ -106,7 +106,7 @@ def test_나무는_좌측_패널_찾기_칸_없음(): assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름 page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert "createTreePanel({" in page and "tree.select(column)" in page - assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + assert "layerBar(tree.add, () => void main.openTable(layer))," in page main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목 diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index 11802c88e..c991c446f 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -87,7 +87,9 @@ def test_머리_고침은_쓸_수_있는_층만_나무로(): assert "readOnly || !extras.onHeadRename" in main page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert "tree.editFromTable(headRenameEdit(col, level, name), col.id)" in page - assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0 + assert ( + "onHeadRename: (col: StructureColumn" in page + ) # 720-4 — 가림은 편집 칸 readOnly · 나무 canEdit tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index dfd6fc510..ae2f60164 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -342,7 +342,7 @@ def test_좌측_판_자리와_권한(): order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),") assert [host.index(k) for k in order] == sorted(host.index(k) for k in order) assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?") - assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 + assert "canWrite: opt.canWrite," in side # 구조물 조합 층 쓰기 권한(720-4 함수) assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") # 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음 @@ -351,7 +351,9 @@ def test_좌측_판_자리와_권한(): assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side - assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부] + assert ( + "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo_side + ) # [별칭 장부] wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts") assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만 diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 8e58b5371..24ab18297 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -181,7 +181,10 @@ def test_층_단추_셋_남의_회사_사람_나무(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert "createLayerBar({" in page and "setActiveLayer(layer, owner)" in page assert "const company = viewCompany(entry, user);" in page - assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0] + # 720-4 — 다른 층 = 저장 안 한 고침 물음 뒤 제자리 바꿈(해시 이동 · 페이지 다시 세움 없음) + go = page.split("const go =")[1].split("};")[0] + assert "await main.confirmLeave()" in go and "await switchStructure(next, nextOwner);" in go + assert "navigateTo" not in page assert 'dispose: () => setActiveLayer("system")' in page assert "side.reveal(entry.open)" in page roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") @@ -192,9 +195,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6) assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침 assert 'L("M02_PeopleEmpty")' in roots - assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page + assert 'const many = want === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page - assert 'peopleTree("구조물조합", tree.root)' in page + assert "peopleTree(tree.root)" in page assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움 panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( @@ -223,8 +226,8 @@ def test_층_길_접두어와_고정_풀림(): in labor ) side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") - assert "const layer = opt.layer;" in side - assert 'layer === "system"' in side # 도면 양식은 시스템 층에만 + assert 'const layer: Layer = "system";' in side # 도면 양식은 시스템 층에만 + assert "const drawing = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘 main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert 'sel.layer !== "system"' not in main assert 'return layer === "project" || isAdmin;' in main @@ -236,7 +239,7 @@ def test_읽기만_회사용_개인용으로_복사_막대(): assert ( "readOnlyBar?.setColumn(column)" in page ) # 열 고름 → 막대 · 구조물 라이브러리가 함께 받음 - assert "accessFor(layer, owner, user).needCompany" in page # 회사 없음 = 마스터로 + assert "if (accessFor(lay, who, user).needCompany)" in page # 회사 없음 = 마스터로 assert "buildNeedCompany()" in _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert 'title: L("M02_Title")' in page # 한 화면 한 제목 layer = _text(LAYER) @@ -290,7 +293,7 @@ def test_켜진_층_단추는_목록으로_복사_끌기(): roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page + assert "layerBar(tree.add, () => void main.openTable(layer))" in page # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page assert "(to === layer && toOwner === here)) return [];" in page @@ -316,12 +319,10 @@ def test_구조물_조합_제목_줄_새_항목(): r"\s*\.\.\.\(extras\.newItem \? \[extras\.newItem\] : \[\]\),?\s*\]", main, ) - assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main + # 720-4 — 쓸 수 있는 층만 = 편집 칸이 고른 것 권한으로 가림(층이 제자리에서 바뀜) + assert "extras.newItem.hidden = !combo || !sel || !writable(sel);" in main page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert ( - "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {})" - in page - ) + assert "newItem: newItemButton(() => void openNew())," in page panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder()) assert "const newItem = (): void => void addItem(folder());" in panel diff --git a/resources/tester/test_m02_practice.py b/resources/tester/test_m02_practice.py index 4466a0ac5..68a5284e9 100644 --- a/resources/tester/test_m02_practice.py +++ b/resources/tester/test_m02_practice.py @@ -51,9 +51,9 @@ def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_co def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만(): side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8") - assert 'layer === "system" && opt.isAdmin' in side + assert "const practice = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘 order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] - assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래 + assert order.index("drawing ?") < order.index("practice ?") # 도면 양식 아래 view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8") assert view.count("showConfirmDialog(") == 2 assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음 diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 660859a49..9aeeab33c 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -72,15 +72,17 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움(): assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음 assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음 - bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split("const side = buildSide(")[0] + bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split( + "const side = buildSide(" + )[0] # 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표 go = bar.split("go: (next) =>")[1].split("reselect:")[0] assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0] assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0] - # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 - assert page.count('layerBar("') == 1 - assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + # 구조물 조합 층 단추 한 벌(720-4 제자리 바꿈) + assert page.count("layerBar(") == 1 + assert "layerBar(tree.add, () => void main.openTable(layer))," in page assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음 @@ -92,7 +94,7 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): assert "listTemplates(at.layer, null, at.owner)" in lib_side main = _text("M02_MasterTemplete_UI_Main.ts") assert ( - "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);" + "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin());" in main ) assert "readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner)" in main diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 67fafe82f..337f7f6e9 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -180,7 +180,7 @@ def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이(): def test_첫_화면과_닫은_뒤는_구조물집계표(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert re.search(r'kind: "table",\s*name: STRUCTURE_TABLE', page) - assert "main.open(sel ?? home)" in page and "void main.open(home);" in page + assert "main.open(sel ?? home())" in page and "void main.open(home());" in page def test_좌측_고름_표시는_열린_것_한_줄뿐(): From 190789cdf713d24879f532e73f24d2db8cec6e49 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:39:17 +0900 Subject: [PATCH 13/23] =?UTF-8?q?feat(722-1=C2=B7722-2):=20M01=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20=E2=80=94=20=EA=B3=B5=EC=9A=A9=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EB=B6=80=ED=92=88(=ED=95=98=EC=9C=84=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=EC=82=B0=EC=8B=9D=20=C2=B7=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=98=B5=EC=85=98)=20=C2=B7=20=EC=A4=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8(=EC=8B=9C=ED=97=98=20=EA=B0=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 공용 단가산출 상세 `M01_MasterData_UI_LogicLab_Ho.ts` — 하위 박스 = 서버 묶음(인력 · 재료 · 경비(장비) · 하위) · 장비 · 하위 로직 = 한 줄 + 펼침(부른 로직 시간당 줄 · 몫 줄 · 다시 셈해도 펼친 채) · 「계산식」 열 = 값 든 식 + 알약 + 그 줄 입력 칩 · 이름 식 = 풍선 · 박스 제목 아래 옵션 고르기 · 어느 박스도 안 쓰는 입력 = 공통 옵션 · 맨 아래 비목 소계 셋 + 계 - 새 `M01_MasterData_UI_LogicLab_Cover.ts` — 줄 모달 「그 자리 고침」(수량 · 단가 · 로직 줄은 노 · 재 · 경) · M01 = 시험 값만(로직 줄 객체 열쇠 메모리 · 시험 계산 줄 사본에 손 단가 · 값묶음 · 수량 덮음 · 정본 · 요소 · 캐시 안 바뀜) · 고침 꼴 = M02 덮개 칸(724 고침 고리 `HoEdit`) - `LogicLab_Detail.ts` 단가산출 상세 = 공용 부품(호표 묶음 표 코드 옮김 · 551 → 252 줄) · `LogicLab.ts` 시험 계산 줄 = trial.apply · dirty 에 시험 고침 · onTrial(줄 수 627 그대로) - `calc_sheet.ts` varRow 내보냄 · 값 없으면 빈 첫 칸 · `LogicLab_Formula.ts` inputChip 내보냄 · `LogicLab_Modal.ts` 줄 모달 edit 칸 - 보기 고르기 넷 그대로(지우기는 사용자 확인 뒤) - 지킴 시험 test_722_ho_detail(박스 묶기 · 고침 덮기 순수 함수 · 캐시 밖 · 부품 상자) Co-Authored-By: Claude Opus 5.5 --- A00_Common/calc_sheet.ts | 10 +- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 12 +- .../M01_MasterData_UI_LogicLab_Cover.ts | 173 +++++ .../M01_MasterData_UI_LogicLab_Detail.ts | 367 +--------- .../M01_MasterData_UI_LogicLab_Formula.ts | 8 +- .../M01_MasterData_UI_LogicLab_Ho.ts | 649 ++++++++++++++++++ .../M01_MasterData_UI_LogicLab_Modal.ts | 3 + .../M01_MasterData_UI_LogicLab_Style.css | 39 ++ resources/tester/test_722_ho_detail.py | 113 +++ 9 files changed, 1030 insertions(+), 344 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts create mode 100644 resources/tester/test_722_ho_detail.py diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index d3edd2d2e..10e0fad4c 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -154,12 +154,16 @@ const out = (v: number | null, key = false, digits = 0): HTMLElement => const lineRow = (line: SheetLine, label = line.이름): HTMLElement => createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1)); -/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ -function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { +/** 변수 칸 하나 — 고르기 있으면 고르기(값 없으면 빈 첫 칸) · 아니면 글 칸(Enter · 떠남 때 넘김) · + * 722-1 공용 단가산출 상세 박스 옵션도 씀 */ +export function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { let control: HTMLElement; if (spec.고르기?.length) { control = createSelectField({ - options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({ + value: o, + text: o, + })), value, compact: true, ariaLabel: spec.이름, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index e32601f29..aee6cc173 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -34,7 +34,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"; +import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail"; import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -91,10 +91,10 @@ interface Opened extends Draft { /** 줄의 흐름(있으면 흐름도로 셈) */ const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; const OWN_FILE = "로직_자체.json"; -/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) */ +/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) · 그 밖은 시험 고침(722-2) 덮은 사본 */ const calcRow = (row: LogicRow | null): LogicRow | null => { const f = flowOf(row); - return f && row ? { ...row, 입력: designFields(f) } : row; + return f && row ? { ...row, 입력: designFields(f) } : trial.apply(row); }; const cacheKey = "m01_logic_drafts_lab"; @@ -218,7 +218,7 @@ export async function mountM01LogicLab( file: current.file, row: calcRow(current.row) as LogicRow, prices: current.prices, - dirty: () => current.id in drafts, + dirty: () => current.id in drafts || trial.has(current.row), values: current.values, onText: (text) => { current.text = text; @@ -233,8 +233,7 @@ export async function mountM01LogicLab( }; const drawEditor = (): void => { - // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened; + const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 list.root.hidden = !listing || !loaded; waiting.hidden = loaded; panes.root.hidden = !opened; @@ -257,6 +256,7 @@ export async function mountM01LogicLab( ownHost: current.ownHost, onChange: touch, onInput: (name, v) => syncLeft(current, { [name]: v }, true), + onTrial: drawCalc, calcHost: calc, flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts new file mode 100644 index 000000000..b1841a53f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Cover.ts + * 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸. + * 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가) + * M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row) + * · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐 + * M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 + * 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음 + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; + +/* ── 순수 셈 ── */ +/** 줄 하나 고침 — 품(수량) · 비목 단가(로직 줄은 셋 · 그 밖은 그 줄 비목 하나) */ +export interface LineCover { + qty?: number; + labor?: number; + material?: number; + expense?: number; +} +export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const; +export type CoverPart = keyof typeof COVER_PARTS; + +/** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */ +const plainNum = (v: number): string => + v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }); + +/** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */ +export function applyCovers( + row: R, + covers: Record, +): R { + const ho = (row.호표 ?? []).map((raw, i) => { + const c = covers[i]; + if (!c) return raw; + const item = raw as Record; + const out: Record = { ...item }; + if (c.qty !== undefined) out.수량 = plainNum(c.qty); + const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined); + if (parts.length && item.종류 === "로직") { + const given = parts.map((k) => [COVER_PARTS[k], c[k]]); + out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) }; + } else if (parts.length) out.단가 = c[parts[0]]; + return out; + }); + return { ...row, 호표: ho }; +} +/* ── 순수 셈 끝 ── */ + +/** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */ +const trials = new WeakMap>(); +export const trial = { + of(row: object): Record { + let got = trials.get(row); + if (!got) trials.set(row, (got = {})); + return got; + }, + has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0, + /** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */ + apply(row: R): R { + return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row; + }, +}; + +const TEXT = { + Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"], + Note: [ + "정본 · 마스터 값(노임 · 자재)은 안 바뀜 · 로직을 다시 열면 사라짐", + "Source and master prices stay unchanged · cleared when reopened", + ], + NoteM02: [ + "고친 값은 이 단가산출의 변경 항목으로 저장 — M01 은 안 바뀜", + "Saved as this item's override — M01 stays unchanged", + ], + Qty: ["수량(품)", "Quantity"], + Price: ["단가", "Unit price"], + Apply: ["적용", "Apply"], + Undo: ["되돌림", "Undo"], + Bad: ["0 이상 수만", "Numbers ≥ 0 only"], +} as const satisfies Record; +export const tc = (k: keyof typeof TEXT): string => + TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface CoverFormOptions { + /** 지금 값(덮기 전 셈 값) — 칸 처음 값 */ + qty: number | null; + /** 단가 칸 — 로직 줄 = 셋(1 단위 비목 값) · 그 밖 = 그 줄 비목 하나 */ + prices: Partial>; + /** 지금 든 고침(있으면 칸 처음 값 · [되돌림]) */ + cover?: LineCover; + /** 「m02」 = 덮개 보관 안내 */ + scope?: "m01" | "m02"; + onApply: (cover: LineCover | null) => void; +} + +const num = (raw: string): number | null => { + const v = Number(raw.replaceAll(",", "").trim()); + return raw.trim() !== "" && Number.isFinite(v) && v >= 0 ? v : null; +}; + +/** 고침 칸 — 수량 · 단가(들) · [적용] [되돌림] · 바뀐 칸만 고침으로 넘김 */ +export function coverForm(o: CoverFormOptions): HTMLElement { + const parts = Object.keys(o.prices) as CoverPart[]; + const field = (label: string, raw: number | null, mine?: number) => { + const now = raw === null ? null : Number(raw.toPrecision(10)); // 칸 글 = 유효 10 자리 · 손대지 않으면 고침 아님 + const box = createInputField({ + label, + value: String(mine ?? now ?? ""), + compact: true, + type: "text", + }); + box.input.inputMode = "decimal"; + return { box, now }; + }; + const qty = field(tc("Qty"), o.qty, o.cover?.qty); + const prices = parts.map((k) => ({ + k, + ...field(parts.length > 1 ? COVER_PARTS[k] : tc("Price"), o.prices[k] ?? null, o.cover?.[k]), + })); + const error = createText("", "error"); + error.hidden = true; + const apply = (): void => { + const next: LineCover = {}; + let bad = false; + const take = (f: { box: { input: HTMLInputElement }; now: number | null }, was?: number) => { + const v = num(f.box.input.value); + if (v === null) + return f.box.input.value.trim() === "" ? undefined : ((bad = true), undefined); + return v !== f.now || was !== undefined ? v : undefined; // 셈 값 그대로면 고침 아님 + }; + const q = take(qty, o.cover?.qty); + if (q !== undefined) next.qty = q; + for (const p of prices) { + const v = take(p, o.cover?.[p.k]); + if (v !== undefined) next[p.k] = v; + } + error.textContent = tc("Bad"); + error.hidden = !bad; + if (!bad) o.onApply(Object.keys(next).length ? next : null); + }; + const actions = el("div", { + className: "m01lab-cover__actions", + children: [ + createButton({ label: tc("Apply"), variant: "filled", size: 32, onClick: apply }), + ...(o.cover + ? [ + createButton({ + label: tc("Undo"), + variant: "ghost", + size: 32, + onClick: () => o.onApply(null), + }), + ] + : []), + ], + }); + return el("section", { + className: "m01lab-cover", + attrs: { "data-cover-form": "" }, + children: [ + el("strong", { text: tc("Head") }), + createText(tc(o.scope === "m02" ? "NoteM02" : "Note"), "hint"), + el("div", { + className: "m01lab-cover__fields", + children: [qty.box.root, ...prices.map((p) => p.box.root)], + }), + error, + actions, + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ba75ddea0..e6cc7b59e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -2,76 +2,29 @@ * M01_MasterData_UI_LogicLab_Detail.ts * 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로 * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로) - * ③ 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) ④ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) - * 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`) + * ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`) + * 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import type { - CalcLine, - ElementBrief, - HoLine, - LogicRow, - NamedFormula, - TextAnswer, - TextLine, - TextPiece, -} from "./M01_MasterData_UI_Logic_Api"; -import { pctView } from "./M01_MasterData_UI_Logic_Api"; -import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; -import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; +import type { LogicRow } from "./M01_MasterData_UI_Logic_Api"; +import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; -import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { buildEst } from "./M01_MasterData_UI_LogicLab_Est"; -import { - condText, - openMaterialModal, - openNoteModal, - openRefModal, - openLogic, - openTableModal, - type PickCond, -} from "./M01_MasterData_UI_LogicLab_Modal"; -import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; -import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; +import { buildHo, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho"; +import { trial } from "./M01_MasterData_UI_LogicLab_Cover"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; -import { createBadge } from "@ui/ui_template_badge"; import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; -/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ -function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] { - return pieces.map((piece): Fragment => { - const dim = piece.깊이 ? { dim: true } : {}; - if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직") - return { text: piece.글, ...dim }; - const ref = piece.참조; - const open = (): void => { - if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick")); - if (piece.kind === "표") - openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 }); - else if (piece.kind === "요소") - openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); - else openLogic(ref.키); - }; - return { pill: { label: piece.글, onClick: open }, ...dim }; - }); -} +export { trial }; -export interface DetailContext { - row: LogicRow; +export interface DetailContext extends HoContext { file: string; reasons: string[]; - prices: Record; - /** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */ - lines: CalcLine[] | null; - /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ - text: TextAnswer | null; - /** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */ - lastText?: TextAnswer; - values: Record; /** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */ ownHost: HTMLElement; onChange: () => void; @@ -82,58 +35,12 @@ export interface DetailContext { flowHost?: HTMLElement; /** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */ admin?: boolean; - /** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */ - onInput?: (name: string, value: string) => void; + /** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */ + onTrial?: () => void; /** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ cover?: { ho: HTMLElement; est: HTMLElement }; } -type Group = "labor" | "material" | "cost" | "other"; -const GROUPS: Group[] = ["labor", "material", "cost", "other"]; -const OF_KIND: Record = { 인력: "labor", 재료: "material", 기계: "cost" }; -const OF_COST: Record = { 노무비: "labor", 재료비: "material", 경비: "cost" }; - -/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */ -const groupOfHo = (item: HoLine): Group => - OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other"; - -interface Entry { - /** 호표 줄 차례(덧줄은 호표 수 + 덧줄 차례) — 서버 조각을 찾는 열쇠 */ - idx: number; - group: Group; - name: string; - spec: string; - unit: string; - qty: string; - /** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */ - qtyValue: string; - qtyTag: string; - qtyFrag: Fragment[]; - /** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */ - share?: string; - price: string; - amount: number | null; - /** 덧줄이면 true */ - extra: boolean; - open: (self: Entry) => void; - /** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */ - unitFrag?: Fragment[]; - piecesFrag?: Fragment[]; - /** 서버 요소조각의 첫 요소 — 자리 표시 든 줄이 입력으로 풀린 키 · 글 */ - unitRef?: string; - unitText?: string; - /** 자재 단위가 줄 단위와 성격이 다름 — 「줄 m · 자재 Ton」 */ - warn?: string; -} - -/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ -const condOf = (item: HoLine): PickCond | null => - typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null; -const condLabel = (cond: PickCond): string => - [cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · "); - -const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey); - const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => el("section", { className: "m01-logic__section m01lab__box", @@ -238,220 +145,6 @@ function infoBox(ctx: DetailContext): HTMLElement[] { return [fold, ...reasons]; } -/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */ -const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—"); - -/** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */ -const spread = (e: Entry, line?: CalcLine): Entry[] => { - const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0); - if (!parts.length) return [e]; - return parts.map(([k, v]) => ({ - ...e, - group: OF_COST[k] ?? "other", - amount: v, - ...(parts.length > 1 - ? { - share: k, - // 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게 - ...(line && line.금액 && line.단가 != null - ? { price: formatPrice((line.단가 * v) / line.금액) } - : {}), - } - : {}), - })); -}; - -function entries(ctx: DetailContext): Entry[] { - const middles: NamedFormula[] = ctx.row.중간 ?? []; - const ho = ctx.row.호표 ?? []; - const out: Entry[] = ho.flatMap((item, i) => { - const line = ctx.lines?.[i]; - const cond = condOf(item); - const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소]; - const price = line ? line.단가 : brief?.값; - const entry: Entry = { - idx: i, - group: groupOfHo(item), - name: item.이름 ?? (cond ? condLabel(cond) : item.요소), - spec: item.규격 ?? brief?.규격 ?? "", - unit: item.단위 ?? "", - qty: item.수량, - qtyValue: qtyOf(line), - qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량), - qtyFrag: [{ text: item.수량 }], - price: price === undefined || price === null ? "" : formatPrice(price), - amount: line ? line.금액 : null, - warn: line?.단위경고, - extra: false, - open: (self) => - openMaterialModal({ - unitFrag: self.unitFrag, - unitRef: line?.품목?.키 ?? self.unitRef, - unitText: self.unitText, - qtyFrag: self.piecesFrag, - title: item.이름 ?? (cond ? condLabel(cond) : item.요소), - expr: item.수량, - ...(cond ? { cond, unit: item.단위 } : { element: item.요소 }), - prices: ctx.prices, - middles, - values: ctx.values, - // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) - kind: item.종류, - usedSlot: line?.품목?.값칸, - ...(item.종류 === "재료" - ? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) } - : {}), - }), - }; - return spread(entry, line); - }); - (ctx.row.덧줄 ?? []).forEach((extra, i) => { - const line = ctx.lines?.[ho.length + i]; - const entry: Entry = { - idx: ho.length + i, - group: OF_COST[extra.비목 ?? ""] ?? "other", - name: extra.이름, - spec: "", - unit: line ? pctView(line).unit : "", - qty: extra.식, - qtyValue: qtyOf(line), - qtyTag: tl("Extra"), - qtyFrag: [{ text: extra.식 }], - price: "", - amount: line ? line.금액 : null, - extra: true, - open: (self) => - openMaterialModal({ - unitFrag: self.unitFrag, - qtyFrag: self.piecesFrag, - title: extra.이름, - expr: extra.식, - prices: ctx.prices, - middles, - values: ctx.values, - }), - }; - out.push(...spread(entry, line)); - }); - attachPieces(out, ctx); - return out; -} - -/** 서버 `/text` 줄의 조각을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 묶음마다 호표 차례대로 줄을 주므로 - * 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 · - * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ -function attachPieces(all: Entry[], ctx: DetailContext): void { - const source = ctx.text?.ok ? ctx.text : ctx.lastText; - if (!source?.ok) { - if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸 - return; - } - const ho = ctx.row.호표 ?? []; - const names = [ - ...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)), - ...(ctx.row.덧줄 ?? []).map((extra) => extra.이름), - ]; - const found = new Map(); - for (const g of source.groups) { - let from = 0; - for (const line of g.줄) { - const at = names.findIndex((n, i) => i >= from && n === line.이름); - if (at < 0) continue; - found.set(`${g.비목}|${at}`, line); - from = at + 1; - } - } - const costs = Object.keys(OF_COST); - for (const e of all) { - const own = costs.find((c) => OF_COST[c] === e.group); - const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean); - const pieces = line?.조각; - e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : []; - if (line?.요소조각?.length) { - e.unitFrag = toFragments(line.요소조각, ctx); - const first = line.요소조각.find((piece) => piece.kind === "요소"); - e.unitRef = first?.참조?.키; - e.unitText = first?.글; - } - } -} - -const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text }); - -function lineRow(e: Entry): HTMLElement { - const tr = el("tr", { - className: `m01lab__line${e.extra ? " m01lab__line--extra" : ""}`, - attrs: { tabindex: "0", "data-line": e.name }, - children: [ - el("td", { - children: [ - el("div", { text: e.name }), - ...(e.share ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), - ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), - ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), - ], - }), - cellOf(e.qtyValue, "m01-logic__money"), - cellOf(e.unit), - el("td", { - className: "m01lab__qty-cell", - children: [createBadge(e.qtyTag, "accent"), fragmentRow(e.qtyFrag)], - }), - cellOf(e.price, "m01-logic__money"), - cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"), - ], - }); - tr.addEventListener("click", () => e.open(e)); - tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e)); - return tr; -} - -function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLElement[] { - const sub = mine.reduce((s, e) => s + (e.amount ?? 0), 0); - const empty = el("tr", { - children: [ - el("td", { - attrs: { colspan: "6" }, - className: "ui-text ui-text--muted", - text: tl("Empty_Group"), - }), - ], - }); - const subtotal = el("tr", { - className: "m01lab__subtotal", - attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" }, - children: [ - el("td", { attrs: { colspan: "5" }, text: `${groupName(group)} ${tl("Subtotal")}` }), - cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"), - ], - }); - const headers = [ - tl("Col_Name"), - tl("Col_Qty"), - tl("Col_Unit"), - tl("Col_Logic"), - tl("Col_Price"), - tl("Col_Amount"), - ]; - return [ - el("h4", { className: "m01lab__group", text: groupName(group) }), - el("div", { - className: "m01-logic__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m01-logic__grid m01lab__ho", - children: [ - el("thead", { - children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })], - }), - el("tbody", { children: [...(mine.length ? mine.map(lineRow) : [empty]), subtotal] }), - ], - }), - ], - }), - ]; -} - /** 자체 로직(키 GX…) — 정본은 아님 */ export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); @@ -477,6 +170,23 @@ function ownEditor(ctx: DetailContext): HTMLElement { }); } +/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */ +function trialEdit(ctx: DetailContext): HoEdit | undefined { + const redraw = ctx.onTrial; + if (!redraw) return undefined; + const covers = trial.of(ctx.row); + return { + covers, + scope: "m01", + set: (idx, cover) => { + if (cover) covers[idx] = cover; + else delete covers[idx]; + redraw(); + ctx.calcHost.querySelector("[data-calc-run]")?.click(); + }, + }; +} + function hoBox(ctx: DetailContext): HTMLElement { const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원"); if (!money) { @@ -488,26 +198,17 @@ function hoBox(ctx: DetailContext): HTMLElement { "ho", ); } - const all = entries(ctx); - const calculated = ctx.lines !== null; - const tables = GROUPS.flatMap((group) => { - const mine = all.filter((e) => e.group === group); - return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만 - }); - const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); - const totalLine = el("p", { - className: "m01lab__total", - attrs: { "data-sum": calculated ? String(total) : "" }, - text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, - }); - // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임) + const body = buildHo({ ...ctx, edit: trialEdit(ctx) }); + const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠 + // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임) const fold = el("details", { className: "m01lab__fold", attrs: ctx.ownHost.dataset.fold ? { open: "" } : {}, - children: [el("summary", { text: tl("Ho_Fold") }), ...tables], + children: [el("summary", { text: tl("Ho_Fold") }), ...body], }); fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : "")); - const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine]; + const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine]; + const calculated = ctx.lines !== null; const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho"); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 4cf179ae1..478eb30cb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -45,8 +45,12 @@ export function usedInputs( } /* ── 순수 셈 끝 ── */ -/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */ -function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement { +/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` · 722-1 계산식 칸도 씀 */ +export function inputChip( + spec: LogicInput, + shown: string, + onInput: (v: string) => void, +): HTMLElement { const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } }); const chip = createButton({ label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts new file mode 100644 index 000000000..d7ac3df33 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -0,0 +1,649 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Ho.ts + * 공용 「단가산출 상세」(PLAN 722-1 · 요구서 내역16 2장 6 · 7) — M01 단가산출 로직 상세가 씀 · (724) M02 단가산출 · 일위대가 모달도. + * 하위 박스 = 서버 `묶음`(인력 · 재료 · 경비(장비) · 하위 — `/text` 줄 덧칸 714-1) · 계산 전 = 호표 종류로 + * · 장비 · 하위 로직 줄 = 박스 안 한 줄(노 · 재 · 경 몫 합) + 누르면 펼침(부른 로직 시간당 줄 · 몫 식 줄) + * 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴) · 어느 박스도 안 쓰는 입력 = 맨 위 「공통 옵션」 + * 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면 + * 맨 아래 = 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계 + * 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`) + * 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createCalcFormulaRow } from "@ui/ui_template_calc_row"; +import { createCard } from "@ui/ui_template_card"; +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { emptyRow, tableRow, tableShell, type Column } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet"; +import type { + CalcLine, + ElementBrief, + HoLine, + LogicRow, + NamedFormula, + TextAnswer, + TextLine, + TextPiece, +} from "./M01_MasterData_UI_Logic_Api"; +import { pctView } from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; +import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; +import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover"; +import { inputChip } from "./M01_MasterData_UI_LogicLab_Formula"; +import { + condText, + openMaterialModal, + openNoteModal, + openRefModal, + openLogic, + openTableModal, + type PickCond, +} from "./M01_MasterData_UI_LogicLab_Modal"; +import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +/* ── 순수 셈 ── */ +/** 박스로 묶을 줄 — 호표 차례 · 박스 이름 · 한 줄로 합칠지(장비 · 하위) · 금액 */ +export interface HoRowLike { + idx: number; + bundle: string; + merge: boolean; + amount: number | null; +} +export interface HoMerged { + idx: number; + 몫: T[]; + 금액: number | null; +} +export interface HoBox { + 묶음: string; + 줄: (T | HoMerged)[]; + 소계: number; +} +const BOX_ORDER = ["인력", "재료", "경비", "하위"]; + +/** 줄 → 박스(인력 · 재료 · 경비 · 하위 차례) — 합칠 줄은 같은 박스 · 같은 호표 차례끼리 한 줄(몫 차례 그대로) · + * `always` 박스는 줄이 없어도 빈 박스로 */ +export function boxesOf(list: T[], always: string[] = []): HoBox[] { + const boxes = new Map>(); + const take = (name: string): HoBox => { + let b = boxes.get(name); + if (!b) boxes.set(name, (b = { 묶음: name, 줄: [], 소계: 0 })); + return b; + }; + always.forEach(take); + const merged = new Map>(); + for (const e of list) { + const b = take(e.bundle); + b.소계 += e.amount ?? 0; + if (!e.merge) { + b.줄.push(e); + continue; + } + const id = `${e.bundle}\n${e.idx}`; + let m = merged.get(id); + if (!m) { + merged.set(id, (m = { idx: e.idx, 몫: [], 금액: null })); + b.줄.push(m); + } + m.몫.push(e); + m.금액 = m.금액 === null && e.amount === null ? null : (m.금액 ?? 0) + (e.amount ?? 0); + } + const rank = (x: string): number => (BOX_ORDER.includes(x) ? BOX_ORDER.indexOf(x) : 9); + return [...boxes.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음)); +} +/* ── 순수 셈 끝 ── */ + +const TEXT = { + 인력: ["인력", "Labor"], + 재료: ["재료", "Material"], + 경비: ["경비(장비)", "Expense (equipment)"], + 하위: ["하위 로직", "Sub logic"], + Common: ["공통 옵션", "Common options"], + Basis: ["기준", "Basis"], + Formula: ["계산식", "Formula"], + Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"], + Fold: ["누르면 펼침", "Click to expand"], + Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"], + Edited: ["시험 고침", "Trial edit"], +} as const satisfies Record; +const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; +const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b); + +/** 고침 고리 — M01 = 시험 값(줄 사본) · M02 = 덮개 보관 · 열쇠 = 호표 줄 차례 */ +export interface HoEdit { + covers: Record; + set: (idx: number, cover: LineCover | null) => void; + scope?: "m01" | "m02"; +} + +export interface HoContext { + row: LogicRow; + prices: Record; + /** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */ + lines: CalcLine[] | null; + /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ + text: TextAnswer | null; + /** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */ + lastText?: TextAnswer; + values: Record; + /** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */ + onInput?: (name: string, value: string) => void; + /** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */ + calcHost?: HTMLElement; + /** 그 자리 고침 고리(없으면 모달은 보기만) */ + edit?: HoEdit; +} + +type Group = "labor" | "material" | "cost" | "other"; +const OF_KIND: Record = { 인력: "labor", 재료: "material", 기계: "cost" }; +const OF_COST: Record = { 노무비: "labor", 재료비: "material", 경비: "cost" }; +const BOX_OF: Record = { + labor: "인력", + material: "재료", + cost: "경비", + other: "하위", +}; +const COST_OF: Record = { labor: "노무비", material: "재료비", cost: "경비" }; +const PART_OF: Record = { + labor: "labor", + material: "material", + cost: "expense", + other: "expense", +}; + +/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */ +const groupOfHo = (item: HoLine): Group => + OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other"; + +interface Entry extends HoRowLike { + group: Group; + name: string; + spec: string; + unit: string; + qty: string; + /** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */ + qtyValue: string; + qtyFrag: Fragment[]; + /** 한 줄이 비목 몫으로 갈렸을 때 이 몫 이름(예 「노무비」) */ + share?: string; + price: string; + /** 갈리기 전 줄 단가(장비 한 줄 머리) */ + linePrice: string; + extra: boolean; + /** 계산 답 줄 · 서버 읽는 식 줄(붙기 전엔 없음) */ + line?: CalcLine; + textLine?: TextLine; + /** 고침 칸 — 로직 줄 = 노 · 재 · 경 · 그 밖 = 그 줄 비목 */ + logic: boolean; + part: CoverPart; + open: (self: Entry) => void; + unitFrag?: Fragment[]; + piecesFrag?: Fragment[]; + unitRef?: string; + unitText?: string; + warn?: string; +} + +/** 서버 조각(`/text` 줄의 `조각`) → 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ +function toFragments(pieces: TextPiece[], ctx: HoContext): Fragment[] { + return pieces.map((piece): Fragment => { + const dim = piece.깊이 ? { dim: true } : {}; + if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직") + return { text: piece.글, ...dim }; + const ref = piece.참조; + const open = (): void => { + if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick")); + if (piece.kind === "표") + openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 }); + else if (piece.kind === "요소") + openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); + else openLogic(ref.키); + }; + return { pill: { label: piece.글, onClick: open }, ...dim }; + }); +} + +/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ +const condOf = (item: HoLine): PickCond | null => + typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null; +const condLabel = (cond: PickCond): string => + [cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · "); +const nameOf = (item: HoLine): string => + item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소); + +/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */ +const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—"); + +/** 계산된 줄은 서버가 준 비목별 몫으로 나눔 — 로직을 부르는 줄 · 장비 줄도 노무비·재료비·경비로 갈림 */ +const spread = (e: Entry, line?: CalcLine): Entry[] => { + const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0); + if (!parts.length) return [e]; + return parts.map(([k, v]) => ({ + ...e, + group: OF_COST[k] ?? "other", + bundle: BOX_OF[OF_COST[k] ?? "other"], + amount: v, + ...(parts.length > 1 + ? { + share: k, + // 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게 + ...(line && line.금액 && line.단가 != null + ? { price: formatPrice((line.단가 * v) / line.금액) } + : {}), + } + : {}), + })); +}; + +/** 줄 모달 — 자료 보기 + (고리 있으면) 그 자리 고침 */ +function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLine }): void { + const middles: NamedFormula[] = ctx.row.중간 ?? []; + const { cond, item } = extra; + const line = e.line; + const edit = ctx.edit; + const perUnit = (k: string): number | null => + line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null; + openMaterialModal({ + unitFrag: e.unitFrag, + unitRef: line?.품목?.키 ?? e.unitRef, + unitText: e.unitText, + qtyFrag: e.piecesFrag, + title: e.name, + expr: e.qty, + ...(item ? (cond ? { cond, unit: item.단위 } : { element: item.요소 }) : {}), + prices: ctx.prices, + middles, + values: ctx.values, + // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) + kind: item?.종류, + usedSlot: line?.품목?.값칸, + ...(item?.종류 === "재료" && ctx.calcHost + ? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) } + : {}), + ...(edit && item && !e.extra + ? { + edit: (close: () => void) => + coverForm({ + qty: line ? line.수량 : null, + prices: e.logic + ? { + labor: perUnit("노무비"), + material: perUnit("재료비"), + expense: perUnit("경비"), + } + : { [e.part]: line ? line.단가 : null }, + cover: edit.covers[e.idx], + scope: edit.scope, + onApply: (c) => (close(), edit.set(e.idx, c)), + }), + } + : {}), + }); +} + +function entries(ctx: HoContext): Entry[] { + const ho = ctx.row.호표 ?? []; + const out: Entry[] = ho.flatMap((item, i) => { + const line = ctx.lines?.[i]; + const cond = condOf(item); + const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소]; + const price = line ? line.단가 : brief?.값; + const group = groupOfHo(item); + const shown = price === undefined || price === null ? "" : formatPrice(price); + const entry: Entry = { + idx: i, + group, + bundle: BOX_OF[group], + merge: false, + name: nameOf(item), + spec: item.규격 ?? brief?.규격 ?? "", + unit: item.단위 ?? "", + qty: item.수량, + qtyValue: qtyOf(line), + qtyFrag: [{ text: item.수량 }], + price: shown, + linePrice: shown, + amount: line ? line.금액 : null, + warn: line?.단위경고, + extra: false, + line, + logic: item.종류 === "로직", + part: OF_COST[item.비목 ?? ""] ? PART_OF[OF_COST[item.비목 ?? ""]] : PART_OF[group], + open: (self) => modalOf(self, ctx, { cond: cond ?? undefined, item }), + }; + return spread(entry, line); + }); + (ctx.row.덧줄 ?? []).forEach((extra, i) => { + const line = ctx.lines?.[ho.length + i]; + const group = OF_COST[extra.비목 ?? ""] ?? "other"; + const entry: Entry = { + idx: ho.length + i, + group, + bundle: BOX_OF[group], + merge: false, + name: extra.이름, + spec: "", + unit: line ? pctView(line).unit : "", + qty: extra.식, + qtyValue: qtyOf(line), + qtyFrag: [{ text: extra.식 }], + price: "", + linePrice: "", + amount: line ? line.금액 : null, + extra: true, + line, + logic: false, + part: PART_OF[group], + open: (self) => modalOf(self, ctx, {}), + }; + out.push(...spread(entry, line)); + }); + attachPieces(out, ctx); + return out; +} + +/** 서버 `/text` 줄을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 비목마다 호표 차례대로 줄을 주므로 + * 이름을 차례로 따라가며 줄 차례에 맞춤 · 붙은 줄의 `묶음` · `장비` 로 박스를 정함 · + * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ +function attachPieces(all: Entry[], ctx: HoContext): void { + const source = ctx.text?.ok ? ctx.text : ctx.lastText; + if (!source?.ok) { + if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸 + return; + } + const names = [...(ctx.row.호표 ?? []).map(nameOf), ...(ctx.row.덧줄 ?? []).map((x) => x.이름)]; + const found = new Map(); + for (const g of source.groups) { + let from = 0; + for (const line of g.줄) { + const at = names.findIndex((n, i) => i >= from && n === line.이름); + if (at < 0) continue; + found.set(`${g.비목}|${at}`, line); + from = at + 1; + } + } + const costs = Object.keys(OF_COST); + const fresh = ctx.lines !== null; // 묶음은 지금 셈 답에서만(옛 답의 묶음으로 박스를 안 옮김) + for (const e of all) { + const own = costs.find((c) => OF_COST[c] === e.group); + const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean); + const pieces = line?.조각; + e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : []; + if (line?.요소조각?.length) { + e.unitFrag = toFragments(line.요소조각, ctx); + const first = line.요소조각.find((piece) => piece.kind === "요소"); + e.unitRef = first?.참조?.키; + e.unitText = first?.글; + } + if (!line || !fresh) continue; + e.textLine = line; + if (line.묶음) e.bundle = line.묶음; + e.merge = !!line.장비 || line.묶음 === "하위"; + } +} + +const COLS: Column[] = [ + { label: tl("Col_Name"), width: "20%" }, + { label: tl("Col_Qty"), width: "9%", align: "end" }, + { label: tl("Col_Unit"), width: "7%" }, + { label: th("Formula"), width: "38%" }, + { label: tl("Col_Price"), width: "13%", align: "end" }, + { label: tl("Col_Amount"), width: "13%", align: "end" }, +]; + +const money = (v: number | null): string => (v === null ? "" : formatMoney(v)); + +/** 이 줄이 쓰는 입력 칩 — 서버 `쓰는입력` ∩ 로직 입력 */ +function chips(e: Entry, ctx: HoContext): HTMLElement[] { + const onInput = ctx.onInput; + const uses = e.textLine?.쓰는입력 ?? []; + const specs = (ctx.row.입력 ?? []).filter((s) => uses.includes(s.이름)); + if (!onInput || !specs.length) return []; + return [ + el("div", { + className: "m01lab-fx__vars", + children: specs.map((s) => inputChip(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))), + }), + ]; +} + +/** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 입력 칩 · 이름 식은 풍선 */ +function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { + const tline = e.textLine; + const main = text || (tline ? sheetFormula(tline.글) : e.qty); + const pills = e.qtyFrag.some((f) => "pill" in f) ? [fragmentRow(e.qtyFrag)] : []; + const cell = el("div", { + className: "m01lab__qty-cell", + children: [ + el("div", { className: "m01lab__qty-text", text: main }), + ...(text ? [] : [...pills, ...chips(e, ctx)]), + ], + }); + cell.title = tline?.이름글 ?? e.qty; + return cell; +} + +function nameCell(e: Entry, label: string, covered: boolean): HTMLElement { + return el("div", { + children: [ + el("div", { text: label }), + ...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), + ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), + ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), + ...(covered ? [createBadge(th("Edited"), "accent")] : []), + ], + }); +} + +/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */ +function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement { + const covered = !!ctx.edit?.covers[e.idx]; + const tr = tableRow(COLS, [ + nameCell(e, label, covered && !e.share), + e.qtyValue, + e.unit, + formulaCell(e, ctx), + e.price, + money(e.amount), + ]); + tr.classList.add("m01lab__line"); + if (e.extra) tr.classList.add("m01lab__line--extra"); + if (covered) tr.classList.add("is-changed"); + tr.tabIndex = 0; + tr.dataset.line = e.name; + tr.addEventListener("click", () => e.open(e)); + tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e)); + return tr; +} + +/** 펼친 장비 줄 — 로직 줄 객체마다 기억(다시 셈 · 다시 그려도 펼친 채) */ +const opened = new WeakMap>(); +const openOf = (row: object): Set => { + let got = opened.get(row); + if (!got) opened.set(row, (got = new Set())); + return got; +}; + +/** 서버 줄 덧칸 — 장비 줄 펼침(714-1b · 부른 기계경비 로직 시간당 줄) */ +type Unfoldable = TextLine & { 펼침?: SheetUnfold | null }; + +/** 장비 · 하위 한 줄 — 머리(노 · 재 · 경 몫 + 합) · 누르면 펼침(부른 로직 시간당 줄 · 몫 줄) */ +function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { + const first = m.몫[0]; + const share = (cost: string): string => + formatMoney( + m.몫 + .filter((x) => (x.share ?? COST_OF[x.group]) === cost) + .reduce((s, x) => s + (x.amount ?? 0), 0), + ); + const short = th("Short") + .replace("{a}", share("노무비")) + .replace("{b}", share("재료비")) + .replace("{c}", share("경비")); + const covered = !!ctx.edit?.covers[m.idx]; + const head = tableRow(COLS, [ + nameCell(first, `▸ ${first.name}`, covered), + first.qtyValue, + first.unit, + formulaCell(first, ctx, short), + first.linePrice, + money(m.금액), + ]); + head.classList.add("m01lab__line"); + if (covered) head.classList.add("is-changed"); + head.dataset.equip = first.name; + head.title = th("Fold"); + head.tabIndex = 0; + head.setAttribute("aria-expanded", "false"); + const unfold = m.몫.map((x) => (x.textLine as Unfoldable | undefined)?.펼침).find(Boolean); + const subs: HTMLTableRowElement[] = [ + ...(unfold + ? [ + tableRow(COLS, [ + { + content: createText( + th("Per") + .replace("{n}", unfold.이름) + .replace("{v}", formatPrice(unfold.계 ?? 0)) + .replace("{u}", unfold.단위 || "-"), + "hint", + ), + colSpan: COLS.length, + }, + ]), + ...unfold.줄.map((x) => + tableRow(COLS, [ + `${x.이름} (${x.비목})`, + "", + "", + sheetFormula(x.글), + "", + money(x.금액), + ]), + ), + ] + : []), + ...m.몫.map((x) => lineRow(x, ctx, `${x.share ?? first.name}`)), + ]; + for (const s of subs) { + s.classList.add("is-sub"); + s.hidden = true; + s.dataset.part = first.name; + } + const label = head.querySelector("td > div > div") as HTMLElement; + const keep = openOf(ctx.row); + const show = (open: boolean): void => { + head.setAttribute("aria-expanded", String(open)); + label.textContent = `${open ? "▾" : "▸"} ${first.name}`; + subs.forEach((s) => (s.hidden = !open)); + if (open) keep.add(m.idx); + else keep.delete(m.idx); + }; + const toggle = (): void => show(head.getAttribute("aria-expanded") !== "true"); + if (keep.has(m.idx)) show(true); + head.addEventListener("click", toggle); + head.addEventListener("keydown", (ev) => { + if (ev.key !== "Enter" && ev.key !== " ") return; + ev.preventDefault(); + toggle(); + }); + return [head, ...subs]; +} + +/** 옵션 고르기 줄들 — 이름 차례 · 고르기 있으면 고르기 · 아니면 글 칸 */ +function optionRows(names: string[], ctx: HoContext): HTMLElement[] { + const onInput = ctx.onInput; + if (!onInput) return []; + return (ctx.row.입력 ?? []) + .filter((s) => names.includes(s.이름)) + .map((s) => varRow(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))); +} + +const usesOf = (b: HoBox): string[] => [ + ...new Set( + b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]).flatMap((e) => e.textLine?.쓰는입력 ?? [])), + ), +]; + +function boxCard(b: HoBox, ctx: HoContext, calculated: boolean): HTMLElement { + const name = boxName(b.묶음); + const lines = b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r])); + const basis = [...new Set(lines.map((e) => e.textLine?.기준단위).filter(Boolean))]; + const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))]; + const { root, tbody } = tableShell(COLS, { density: "data" }); + root.classList.add("m01lab__ho"); + tbody.append( + ...(b.줄.length + ? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) + : [emptyRow(COLS, tl("Empty_Group"))]), + ); + const sub = tableRow(COLS, [ + { content: `${name} ${tl("Subtotal")}`, colSpan: 5 }, + calculated ? formatMoney(b.소계) : "—", + ]); + sub.classList.add("m01lab__subtotal"); + sub.dataset.box = b.묶음; + sub.dataset.subtotal = calculated ? String(b.소계) : ""; + tbody.append(sub); + const card = createCard({ + title: name, + pad: 12, + body: [ + ...(basis.length ? [createText(`${th("Basis")} ${basis.join(" · ")}`, "muted")] : []), + ...(notes.length ? [createText(notes.join(" · "), "hint")] : []), + ...optionRows(usesOf(b), ctx), + root, + ], + }); + card.classList.add("m01lab__ho-box"); + card.dataset.hoBox = b.묶음; + return card; +} + +/** 어느 박스 줄도 안 쓰는 입력 — 맨 위 한 카드 */ +function commonCard(rows: HTMLElement[]): HTMLElement { + const card = createCard({ title: th("Common"), pad: 12, body: rows }); + card.classList.add("m01lab__ho-box"); + card.dataset.hoBox = "common"; + return card; +} + +/** 공용 단가산출 상세 몸 — 공통 옵션 · 박스들 · 비목 소계 셋 · 계 */ +export function buildHo(ctx: HoContext): HTMLElement[] { + const all = entries(ctx); + const calculated = ctx.lines !== null; + const boxes = boxesOf(all, ["인력", "재료", "경비"]); + const used = new Set(boxes.flatMap(usesOf)); + const common = optionRows( + (ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)), + ctx, + ); + const sums = (["labor", "material", "cost"] as Group[]).map((g) => { + const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0); + const out = el("span", { + className: "ui-calc-row__out is-key", + text: calculated ? formatMoney(v) : "—", + }); + const row = createCalcFormulaRow(COST_OF[g], null, out); + row.dataset.group = g; + row.dataset.subtotal = calculated ? String(v) : ""; + return row; + }); + const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); + return [ + ...(common.length ? [commonCard(common)] : []), + ...boxes.map((b) => boxCard(b, ctx, calculated)), + el("div", { className: "m01lab__sums", children: sums }), + el("p", { + className: "m01lab__total", + attrs: { "data-sum": calculated ? String(total) : "" }, + text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, + }), + ]; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index bb3270f83..619b2a157 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -92,6 +92,8 @@ export interface ModalTarget { usedSlot?: string; /** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */ onPick?: (item: { ref: string; label: string }) => void; + /** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 */ + edit?: (close: () => void) => HTMLElement; } /** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ @@ -503,6 +505,7 @@ export function openMaterialModal(target: ModalTarget): void { rate.append(muted(`${element} — ${tl("Pill_NoValue")}`)); } body.replaceChildren( + ...(target.edit ? [target.edit(close)] : []), rate, ...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []), muted(tl("Modal_Sum")), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index a1fbc0bd6..947469e4c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -233,3 +233,42 @@ gap: var(--spacing-4); min-width: 0; } + +/* 722-1 공용 단가산출 상세 — 하위 박스(카드) 안 · 옵션 줄 · 표 사이 간격 · 맨 아래 비목 소계 */ +.m01lab__ho-box > .ui-card__body { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01lab__sums { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* 722-2 줄 모달 「그 자리 고침」 */ +.m01lab-cover { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01lab-cover__fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--spacing-8); +} + +.m01lab-cover__actions { + display: flex; + gap: var(--spacing-8); +} + +.m01lab__ho-box .ui-card__title { + font-size: var(--text-body); + font-weight: 600; +} diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py new file mode 100644 index 000000000..b13834ea6 --- /dev/null +++ b/resources/tester/test_722_ho_detail.py @@ -0,0 +1,113 @@ +"""722 M01 단가산출 상세 개편 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음). + +- 722-1 공용 단가산출 상세(`M01_MasterData_UI_LogicLab_Ho.ts`) — 하위 박스(서버 묶음) · 장비 한 줄 + 펼침 · 계산식 열 · 박스 옵션 +- 722-2 그 자리 고침(`M01_MasterData_UI_LogicLab_Cover.ts`) — M01 = 시험 값(줄 사본) · 정본 · 캐시 안 바뀜 +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M01 = ROOT / "M01_MasterData" +HO = M01 / "M01_MasterData_UI_LogicLab_Ho.ts" +COVER = M01 / "M01_MasterData_UI_LogicLab_Cover.ts" + + +def _text(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def _pure(p: Path) -> str: + s = _text(p) + return s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")] + + +def _node(code: str): + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", code], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_상세가_공용_부품을_씀(): + detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts") + assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail + ho = _text(HO) + # 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식) + for part in ( + "tableShell(", + "tableRow(", + "createCard(", + "varRow(", + "inputChip(", + "sheetFormula(", + ): + assert part in ho, part + for s in (ho, _text(COVER)): + assert 'el("table"' not in s and 'el("button"' not in s and 'el("input"' not in s + assert "document.createElement" not in s and "sessionStorage" not in s + assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts") + for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"): + assert len(_text(p).splitlines()) <= ( + 627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700 + ) + + +def test_시험_고침은_캐시_밖(): + lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") + # 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄 + assert ": trial.apply(row);" in lab + assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab + assert "onTrial: drawCalc," in lab + cover = _text(COVER) + assert "drafts" not in cover and "touch" not in cover and "/save" not in cover + modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts") + assert "...(target.edit ? [target.edit(close)] : [])" in modal + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_박스_묶기(): + body = """ +const e = (idx, bundle, merge, amount) => ({ idx, bundle, merge, amount }); +const got = boxesOf([ + e(0, "인력", false, 10), e(1, "경비", true, 100), e(1, "경비", true, 20), e(1, "경비", true, 30), + e(2, "하위", true, 5), e(2, "하위", true, null), e(3, "재료", false, null), +], ["인력", "재료", "경비"]).map((b) => ({ 묶음: b.묶음, 소계: b.소계, + 줄: b.줄.map((r) => "몫" in r ? [r.idx, r.금액, r.몫.length] : r.idx) })); +console.log(JSON.stringify(got)); +""" + got = _node(_pure(HO) + "\n" + body) + assert [b["묶음"] for b in got] == ["인력", "재료", "경비", "하위"] + assert got[0] == {"묶음": "인력", "소계": 10, "줄": [0]} + assert got[1] == {"묶음": "재료", "소계": 0, "줄": [3]} + assert got[2] == {"묶음": "경비", "소계": 150, "줄": [[1, 150, 3]]} # 장비 한 줄 = 몫 셋 합 + assert got[3]["줄"] == [[2, 5, 2]] + empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))') + assert empty == [{"묶음": "인력", "줄": [], "소계": 0}] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_고침_덮기(): + body = """ +const row = { 키: "GF1", 호표: [ + { 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" }, + { 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } }, + { 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] }; +const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } }); +console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] })); +""" + got = _node(_pure(COVER) + "\n" + body) + out = got["out"] + assert ( + out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000 + ) # 지수 꼴 없음 · 손 단가 + assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1] + assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로 + assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}] From 0e24c7a2aa45e4feb555bc7e1fccab5521284b72 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:45:11 +0900 Subject: [PATCH 14/23] =?UTF-8?q?feat(721-2):=20M02=20=EC=B0=BE=EC=95=84?= =?UTF-8?q?=20=EB=84=A3=EA=B8=B0=20=EC=B0=BD=20=E2=80=94=20=ED=83=AD=20M01?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4(=EB=A1=9C=EC=A7=81=20=C2=B7=20=EC=9D=B8?= =?UTF-8?q?=EB=A0=A5=20=C2=B7=20=EC=9E=AC=EB=A3=8C)=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=EC=B9=B8=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=C2=B7=20=EA=B1=B0=EB=A6=84=20=EA=B0=92=20?= =?UTF-8?q?=3D=20=EA=B5=AC=EB=B6=84=20=C2=B7=20=EC=B6=9C=EC=B2=98=20?= =?UTF-8?q?=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 M02_MasterTemplete_Find_Pick.ts — 있는 길 넷 이음(기준 목록 · 로직 · 층 서고 목록 · /logic-lib/pick 인력 job · 재료 price 서버 찾기) · 기계 · 실무 호표 뺌 - 부르는 곳 셋: 단가산출 [기본 로직 선택](로직 줄만) · [항목 추가](더한줄 인력 · 재료 = 요소 키 LB · MT 수량 1) · 일위대가 [로직 추가](Labor_Pick 이 lib.sangeun 이면 새 창) - 일위대가 인력 · 재료 = 고정 줄 이음 꼴 fixedOf · lib.fixed 받는 칸은 723(sub1) — 그 전엔 회색 막힘 - 옛 창(B09 둘 · 불러오기 둘 · 프로젝트 층) 그대로 · 옛 창의 단가산출 탭 코드 걷음(부르는 곳 없음) - 부품 상자: 데이터 표 흐린 줄 is-dim 더함 - 낡은 시험 test_717_4_sangeun_pick: 옛 창 단가산출 탭 · 재료 숨은 한 줄 글 대조 → 새 창 꼴로 고침 - 새 지킴 시험 test_721_find_pick(부르는 곳 · 옛 창 그대로 · 인력 · 재료 줄이 서버 셈에서 노임 · 자재 값) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Find_Pick.ts | 325 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Pick.ts | 105 ++---- .../M02_MasterTemplete_Sangeun_Detail.ts | 19 +- .../M02_MasterTemplete_Sangeun_Pick.ts | 55 ++- resources/tester/test_717_4_sangeun_pick.py | 19 +- resources/tester/test_721_find_pick.py | 106 ++++++ ui_template/ui_template_data_table.css | 5 + ui_template/ui_template_data_table.ts | 2 +- ui_template/ui_template_locale_m2.ts | 9 +- 9 files changed, 506 insertions(+), 139 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts create mode 100644 resources/tester/test_721_find_pick.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts new file mode 100644 index 000000000..191848fd2 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -0,0 +1,325 @@ +/* ============================================================================= + * M02_MasterTemplete_Find_Pick.ts + * M02 찾아 넣기 창(PLAN 721-2 · 요구서_내역16 1장 6 · 7 · 2장 1 · 2) — 탭 [M01 전체 | M02 단가산출 | 일위대가] · + * 찾기 칸 하나 · 표 하나(품명 · 규격 · 단위 · 금액 · 구분 · 출처) · 옛 거름 값(종류 · 공종 · 구분 · 상세구분 · 층) = 「구분」 · 「출처」 열 + * · M01 전체 = 단가산출 로직(기준 목록 `/logic-lib/standard` · 줄 누름 = 계산서 보기 → [담기]) + + * 인력(값 있는 노임 직종) · 재료(자재품목) = `/logic-lib/pick` 서버 찾기(친 낱말 · 상한 200) · 기계 뺌 · 실무 호표 뺌 + * · M02 단가산출 · 일위대가 = 위 층(마스터 › 회사 › 개인) 서고 목록(`/layers/{층}/templates`) · 고름 = 참조 {층 · 주인 · 키} + * · 인력 · 재료 고름 = `element` · 일위대가 고정 줄 꼴 = `fixedOf`(705-1 이음 노임 LB · 자재 MT · 수량 1 은 부르는 쪽) · + * `element` 없으면 그 줄 회색 막힘(까닭 = 일위대가 표 개편 뒤 · 723) + * · 부르는 곳 = M02 단가산출 [기본 로직 선택](`logicOnly`) · [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가] + * (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`) + * ========================================================================== */ + +import { createDataTable } from "@ui/ui_template_data_table"; +import { createInputField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; +import { openModal } from "@ui/ui_template_modal"; +import { createTabs } from "@ui/ui_template_tabs"; +import { createText } from "@ui/ui_template_text"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import type { LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchLibLogics, fetchStandard, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { ownOwner } from "./M02_MasterTemplete_Layer"; +import { listLayerDocs, SANGEUN_KIND, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 +import "./M02_MasterTemplete_Labor.css"; + +type Scope = Omit; +type Tab = "m01" | "sk" | "pre"; + +/** M01 인력 · 재료 한 줄(`/logic-lib/pick` · 코드 = LB- · MT- 키) */ +export interface M01Element { + 종류: "인력" | "재료"; + 코드: string; + 이름: string; + 규격: string; + 단위: string; + 값: number | null; +} + +export interface FindPickOptions { + layer: Layer; + owner: string | null; + title?: string; + /** M01 단가산출 로직 고름 */ + logic: (x: LogicSummary) => void; + /** M02 단가산출 · 일위대가 고름(참조) */ + ref?: (ref: LaborRef, name: string) => void; + /** M01 인력 · 재료 고름 — 없으면 그 줄 회색 막힘 */ + element?: (e: M01Element) => void; + /** 로직 줄만(탭 · 인력 · 재료 없음 — [기본 로직 선택]) */ + logicOnly?: boolean; +} + +/** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */ +export function fixedOf(e: M01Element): LaborFixed { + const labor = e.종류 === "인력"; + return { + 이름: e.이름, + 규격: e.규격, + 단위: e.단위, + 단가: { 노무비: labor ? (e.값 ?? 0) : 0, 재료비: labor ? 0 : (e.값 ?? 0), 경비: 0 }, + 이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }, + }; +} + +/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ +export async function upper(layer: Layer, owner: string | null): Promise { + if (layer === "project") return []; + const own = + layer === "system" + ? null + : (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null))); + const out: Scope[] = [{ 층: "system", 주인: null }]; + if (own) out.push({ 층: "company", 주인: own.split("/")[0] }); + if (own && layer === "personal") out.push({ 층: "personal", 주인: own }); + return out; +} + +const LAYER_TEXT: Record = { + system: "Lib_System", + company: "Lib_Company", + personal: "Lib_Personal", +}; +/** 노임 「원/일」 → 줄 단위 「인」 · 「원/시」 → 「시」 · 재료 단위 그대로 */ +const unitOf = (u: string): string => (u === "원/일" ? "인" : u.replace(/^원\//, "")); +const join = (...xs: Array): string => xs.filter(Boolean).join(" · "); + +interface PickItem { + ref: string; + 구분?: string | null; + 이름: string; + 규격?: string | null; + 단위?: string | null; + 값: unknown; +} +/** 표 한 줄 + 누름 */ +interface Line { + 품명: string; + 규격: string; + 단위: string; + 금액: number | null; + 구분: string; + 출처: string; + dim?: boolean; + go: () => void; +} + +export function openFindPicker(opt: FindPickOptions): void { + let tab: Tab = "m01"; + let close = (): void => {}; + let logics: LogicSummary[] = []; + let std: StandardRow[] | null = null; + let people: PickItem[] = []; + let goods: PickItem[] = []; + const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null }; + let shown: Line[] = []; + let seq = 0; + let timer = 0; + + const search = createInputField({ type: "search", placeholder: L("M02_FindSearch") }); + const hint = createText(""); + const table = createDataTable({ + caption: L("M02_FindTitle"), + columns: [ + { key: "품명", label: tc("Std_Name"), width: 200 }, + { key: "규격", label: tc("Std_Spec"), width: 110 }, + { key: "단위", label: tc("Std_Unit"), width: 44 }, + { key: "금액", label: tc("Std_Money"), kind: "number", width: 84 }, + { key: "구분", label: L("M02_FindClass"), width: 180 }, + { key: "출처", label: tc("Std_Source"), width: 120 }, + ], + rows: [], + rowClass: (i) => (shown[i]?.dim ? "is-dim" : null), + onRowClick: (i) => shown[i]?.go(), + }); + const take = (f: () => void) => (): void => { + close(); + f(); + }; + + const logicLines = (q: string): Line[] => + (std ?? logics.map(plain)).flatMap((x) => { + const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1 + if (!hit.ok) return []; + const logic = logics.find((y) => y.키 === x.키); + return [ + { + 품명: join(x.이름, aliasNote(hit.alias)), + 규격: x.규격, + 단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`, + 금액: x.금액, + 구분: join(L("M02_FindLogic"), x.공종, x.구분, x.상세구분), + 출처: x.출처, + go: () => + logic && + openStandardSheet( + x, + take(() => opt.logic(logic)), + ), + }, + ]; + }); + const elementLines = (kind: M01Element["종류"], items: PickItem[]): Line[] => + items.map((m) => { + const e: M01Element = { + 종류: kind, + 코드: m.ref, + 이름: m.이름, + 규격: m.규격 ?? "", + 단위: unitOf(m.단위 ?? ""), + 값: Number.isFinite(Number(m.값)) && m.값 !== null ? Number(m.값) : null, + }; + const put = opt.element; + return { + 품명: m.이름, + 규격: e.규격, + 단위: e.단위, + 금액: e.값, + 구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")), + 출처: `M01 ${m.ref}`, + dim: !put, + go: put ? take(() => put(e)) : () => showToast(L("M02_FindElementLater"), "warning"), + }; + }); + const docLines = (rows: SangeunRow[], kind: string): Line[] => + rows.map((x) => ({ + 품명: x.제목 || x.이름, + 규격: "", + 단위: x.단위 ?? "", + 금액: x.기준금액, + 구분: join(kind, x.구분, x.상세구분), + 출처: LAYER_TEXT[x.ref.층] ? tc(LAYER_TEXT[x.ref.층]) : x.ref.층, + go: take(() => opt.ref?.(x.ref, x.제목 || x.이름)), + })); + + function draw(): void { + const q = search.input.value.trim(); + if (tab === "m01") { + shown = logicLines(q); + if (!opt.logicOnly) + shown.push(...elementLines("인력", people), ...elementLines("재료", goods)); + } else { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const kind = tab === "sk" ? L("M02_PickSangeun") : L("M02_PrecomboTitle"); + shown = docLines(lists[tab] ?? [], kind).filter((x) => { + const text = join(x.품명, x.구분).toLowerCase(); + return words.every((w) => text.includes(w)); + }); + } + const loading = tab === "m01" ? !logics.length : !lists[tab]; + hint.textContent = loading ? tc("Loading") : shown.length ? `${shown.length}` : tc("Pick_None"); + table.setRows(shown.map(({ go: _go, dim: _dim, ...row }) => row)); + } + + /** 인력 · 재료 = 서버 찾기(친 낱말) — 재료는 낱말 있을 때만 · 늦게 온 답은 버림 */ + async function findElements(): Promise { + if (opt.logicOnly) return; + const mine = ++seq; + const q = search.input.value.trim(); + const get = (kind: "job" | "price"): Promise => + call<{ items: PickItem[] }>( + `/logic-lib/pick?kind=${kind}&q=${encodeURIComponent(q)}&limit=200`, + ).then((d) => d.items); + try { + const [p, g] = await Promise.all([get("job"), q ? get("price") : Promise.resolve([])]); + if (mine !== seq) return; + [people, goods] = [p, g]; + } catch (error) { + if (mine !== seq) return; + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + if (tab === "m01") draw(); + } + + search.input.addEventListener("input", () => { + draw(); + window.clearTimeout(timer); + timer = window.setTimeout(() => void findElements(), 250); + }); + + const tabs = opt.logicOnly + ? null + : createTabs({ + tabs: [ + { key: "m01", label: L("M02_FindM01") }, + { key: "sk", label: L("M02_PickSangeun") }, + { key: "pre", label: L("M02_PrecomboTitle") }, + ], + value: tab, + kind: "line", + onChange: (key) => { + tab = key as Tab; + draw(); + }, + }); + + ({ close } = openModal({ + title: opt.title ?? L("M02_FindTitle"), + closeLabel: tc("Pick_Close"), + dialogClass: "m01-logic__pick m01-combo", + width: "960px", // 표 여섯 칸 + backdropClass: "m01-logic__backdrop", + mount: (body) => + body.append( + el("div", { + className: "m02-std-pick", + attrs: { "data-find-pick": "" }, + children: [...(tabs ? [tabs.root] : []), search.root, hint, table.root], + }), + ), + })); + search.input.focus(); + draw(); + + const fail = (error: unknown): void => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)]) + .then(([l, s]) => { + logics = l; + std = s?.줄.length ? s.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로 + draw(); + }) + .catch(fail); + void findElements(); + if (tabs) + void upper(opt.layer, opt.owner) + .then((scopes) => + Promise.all( + (["sk", "pre"] as const).map((t) => + listLayerDocs(scopes, t === "sk" ? SANGEUN_KIND : "precombo").then((rows) => { + lists[t] = rows; + tabs.setBadge(t, rows.length); + }), + ), + ), + ) + .catch((error) => { + lists.sk ??= []; + lists.pre ??= []; + fail(error); + }) + .finally(draw); +} + +/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ +const plain = (x: LogicSummary): StandardRow => ({ + 키: x.키, + 이름: x.이름, + 규격: "", + 단위: x.결과단위.replace(/^원\/?/, ""), + 금액: null, + 출처: x.출처 ?? "", + 공종: "", + 별칭: x.별칭 ?? "", + 구분: x.구분, + 상세구분: x.상세구분, + 막힘: x.blocked, + 바꿈: {}, +}); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 36381ec7c..3e30c7c38 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -9,8 +9,9 @@ * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) - * 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를 - * 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키) + * 721-2 `lib.sangeun`(M02 일위대가 [로직 추가] · `withHopyo`) = 찾아 넣기 창(`Find_Pick` · 탭 M01 전체 · 단가산출 · + * 일위대가 · 실무 호표 줄 없음) · M01 인력 · 재료 = `lib.fixed`(일위대가 고정 줄 · 없으면 회색 막힘) · + * B09 · 불러오기는 이 창 그대로 * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -19,7 +20,6 @@ import { createFindList } from "@ui/ui_template_find_list"; import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { fetchLibLogics as fetchLogics, @@ -30,8 +30,7 @@ import { type StandardRow, } from "./M02_MasterTemplete_Labor_Api"; import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; -import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; -import { ownOwner } from "./M02_MasterTemplete_Layer"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; @@ -39,8 +38,7 @@ import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; -import { createTabs } from "@ui/ui_template_tabs"; -import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; +import { fixedOf, openFindPicker, upper } from "./M02_MasterTemplete_Find_Pick"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -75,22 +73,12 @@ export interface LibraryPick { more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; - /** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */ + /** 721-2 찾아 넣기 창(`Find_Pick`)으로 — M02 일위대가 [로직 추가] */ sangeun?: boolean; + /** 721-2 M01 인력 · 재료 → 일위대가 고정 줄(705-1 이음 꼴 · 담은로직 `{로직: "", 고정, 수량}`) */ + fixed?: (fx: LaborFixed, name: string) => void; } -/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ -async function upper(layer: Layer, owner: string | null): Promise { - if (layer === "project") return []; - const own = - layer === "system" - ? null - : (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null))); - const out: Scope[] = [{ 층: "system", 주인: null }]; - if (own) out.push({ 층: "company", 주인: own.split("/")[0] }); - if (own && layer === "personal") out.push({ 층: "personal", 주인: own }); - return out; -} const LAYER_TEXT = { system: "Lib_System", company: "Lib_Company", @@ -103,6 +91,17 @@ export function openLogicPicker( lib?: LibraryPick, pre?: { query?: string }, ): void { + if (lib?.sangeun && lib.layer !== "project") { + const fixed = lib.fixed; + return openFindPicker({ + layer: lib.layer, + owner: lib.owner, + title: lib.title, + logic: done, + ref: lib.pick, + element: fixed && ((e) => fixed(fixedOf(e), e.이름)), + }); + } const subs: SubBrief[] = []; let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; @@ -114,8 +113,6 @@ export function openLogicPicker( detail.select.disabled = true; let close = (): void => {}; let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈 - let tab: "std" | "sk" = "std"; - let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null /* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */ const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); @@ -171,7 +168,6 @@ export function openLogicPicker( const find = (): void => { const q = finder.search.value.trim().toLowerCase(); - if (tab === "sk") return findSangeun(q); const rows = std ?? all.map(plain); const found = rows.filter( (x) => @@ -250,32 +246,6 @@ export function openLogicPicker( })), ]); }; - /** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */ - const findSangeun = (q: string): void => { - const rows = (skRows ?? []).filter( - (x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q), - ); - finder.hint.textContent = !skRows - ? tc("Loading") - : rows.length - ? `${rows.length} / ${skRows.length}` - : tc("Pick_None"); - rowPick = rows.map((x) => () => { - close(); - lib?.pick(x.ref, x.제목 || x.이름); - }); - table.root.hidden = false; - table.setRows( - rows.map((x) => ({ - 품명: x.제목 || x.이름, - 규격: [x.구분, x.상세구분].filter(Boolean).join(" "), - 단위: x.단위 ?? "", - 금액: x.기준금액, - 출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`, - })), - ); - finder.setRows([]); - }; const loadLib = async (): Promise => { if (!lib || !scope) return; const mine = ++seq; @@ -340,45 +310,12 @@ export function openLogicPicker( layerBar.style.display = "none"; const hint = createText(tc("Std_Hint")); - /** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */ - const tabs = lib?.sangeun - ? createTabs({ - tabs: [ - { key: "std", label: L("M02_PickStd") }, - { key: "sk", label: L("M02_PickSangeun") }, - ], - value: tab, - kind: "line", - onChange: (key) => { - tab = key === "sk" ? "sk" : "std"; - const std = tab === "std"; - for (const f of [kindSel?.root, work.root, sub.root, detail.root]) - if (f) f.style.display = std ? "" : "none"; - layerBar.style.display = std && scopes.length ? "" : "none"; - hint.hidden = !std; - find(); - }, - }) - : null; if (lib) void upper(lib.layer, lib.owner).then((got) => { scopes = got; - if (tabs) - listSangeun(got).then( - (rows) => { - skRows = rows; - tabs.setBadge("sk", rows.length); - find(); - }, - (error) => { - skRows = []; - find(); - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); - }, - ); scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 if (!got.length) return; - layerBar.style.display = tab === "std" ? "" : "none"; + layerBar.style.display = ""; drawLayers(); void loadLib(); void fetchStructureDef().then( @@ -401,7 +338,7 @@ export function openLogicPicker( body.append( el("div", { className: "m02-std-pick", - children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root], + children: [finder.root, hint, table.root], }), ), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 3bbbde1dc..038660563 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -27,7 +27,7 @@ import { import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { calcSangeun, skCoverTable } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { @@ -101,12 +101,17 @@ export function mountSangeunDetail( variant: "filled", size: 28, onClick: () => - openLogicPicker((x) => { - doc.바탕 = { 로직: x.키 }; - delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 - if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; - touch(false); - void loadBase(); + openFindPicker({ + ...scope, + logicOnly: true, + title: L("M02_SangeunPickBase"), + logic: (x) => { + doc.바탕 = { 로직: x.키 }; + delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 + if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; + touch(false); + void loadBase(); + }, }), }); pick.hidden = true; // 보임은 drawHead 가 정함 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts index 39d004145..8bcf49dd2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -1,19 +1,16 @@ /* ============================================================================= * M02_MasterTemplete_Sangeun_Pick.ts * M02 단가산출(SK) 고르기(PLAN 717-4) — 위 층 SK 목록 한 벌 · 단가산출 더한줄 고르기. - * · `listSangeun` = 층 목록 길(`/layers/{층}/templates` · 종류 `sangeun`)을 마스터 › 회사 › 개인 차례로 이음 · - * 금액 = 서버가 붙인 1 단위 `기준금액` · 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 출처가 씀 + * · `listLayerDocs` = 층 목록 길(`/layers/{층}/templates` · 종류 하나)을 마스터 › 회사 › 개인 차례로 이음 · + * 금액 = 서버가 붙인 1 단위 `기준금액` · `listSangeun` = 종류 `sangeun` · 찾아 넣기 창(`Find_Pick`) 탭 · B09 재료 창고 출처가 씀 * · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침) - * · `openSangeunLinePicker` = 단가산출 더한줄 고르기 — 같은 [로직 추가] 창(M01 로직 · 일위대가 · 단가산출 · - * M01 재료) → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴) + * · `openSangeunLinePicker` = 단가산출 [항목 추가] — 찾아 넣기 창(721-2 · M01 로직 · 인력 · 재료 · 단가산출 · 일위대가) + * → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴 · 인력 · 재료 = 요소 키 LB · MT · 수량 1) * ========================================================================== */ -import { t as L } from "@ui/ui_template_locale"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { pickMaterial } from "./M02_Material_Pick"; export const SANGEUN_KIND = "sangeun"; /** 층 차례 — 위에서 아래 */ @@ -41,7 +38,14 @@ interface ListRow { } /** 위 층 SK — 층 차례(마스터 › 회사 › 개인) · 층 안은 서버 차례 */ -export async function listSangeun(scopes: Omit[]): Promise { +export const listSangeun = (scopes: Omit[]): Promise => + listLayerDocs(scopes, SANGEUN_KIND); + +/** 위 층 서고 문서 한 종류(sangeun · precombo) — 층 차례 · 층 안은 서버 차례 */ +export async function listLayerDocs( + scopes: Omit[], + kind: string, +): Promise { const rank = (x: Omit): number => LAYER_ORDER.indexOf(x.층 as (typeof LAYER_ORDER)[number]); const sorted = [...scopes].sort((a, b) => rank(a) - rank(b)); @@ -50,7 +54,7 @@ export async function listSangeun(scopes: Omit[]): Promise(`/layers/${at.층}/templates${q}`); return got.양식 - .filter((r) => r.종류 === SANGEUN_KIND) + .filter((r) => r.종류 === kind) .map((r) => ({ ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }, 이름: r.이름, @@ -69,36 +73,17 @@ export async function listSangeun(scopes: Omit[]): Promise; 수량: number } | { 참조: LaborRef; 수량: number } - | { 종류: "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; -/** 단가산출 더한줄 고르기 — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ +/** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ export function openSangeunLinePicker( scope: { layer: Layer; owner: string | null }, done: (line: SangeunLine) => void, ): void { - const element = L("M02_PickElement"); - openLogicPicker((x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), { + openFindPicker({ ...scope, - sangeun: true, - pick: (ref) => done({ 참조: ref, 수량: 1 }), - kindFilter: [ - { value: "logic", text: tc("Kind_Logic") }, - { value: "precombo", text: L("M02_PrecomboTitle") }, - { value: "element", text: element }, - ], - extra: [ - { - kind: "element", - key: "element:material", - tag: element, - name: L("M02_PickMaterial"), - sub: "", - unit: "", - onPick: () => - pickMaterial((m) => - done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 }), - ), - }, - ], + logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), + ref: (ref) => done({ 참조: ref, 수량: 1 }), + element: (e) => done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }), }); } diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index c2c14859a..dad482a19 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -27,20 +27,21 @@ def test_sk_list_layer_order_and_ref_shape(): order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1) assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"] assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick - assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert "r.종류 === kind" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert "listLayerDocs(scopes, SANGEUN_KIND)" in pick assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick - labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") - assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조 - assert 'if (tab === "sk") return findSangeun(q);' in labor + # 721-2 — SK · 일위대가 탭은 찾아 넣기 창(Find_Pick) · 줄 누름 = 참조 + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + assert "go: take(() => opt.ref?.(x.ref, x.제목 || x.이름))" in find assert "sangeun: true," in _text( M02 / "M02_MasterTemplete_Hopyo_Pick.ts" - ) # 일위대가 [로직 추가] + ) # 일위대가 [로직 추가] → 찾아 넣기 창 store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts") assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts - # 더한줄 고르기 — 715 이음 꼴 셋 - assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick - assert "done({ 참조: ref, 수량: 1 })" in pick - assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick + # 더한줄 고르기 — 715 이음 꼴 셋(인력 · 재료 = 요소 키) + assert 'logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick + assert "ref: (ref) => done({ 참조: ref, 수량: 1 })" in pick + assert "done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 })" in pick def _sk(folder, name): diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py new file mode 100644 index 000000000..43aec8847 --- /dev/null +++ b/resources/tester/test_721_find_pick.py @@ -0,0 +1,106 @@ +"""721 M02 찾아 넣기 창 — 단추 이름 · 자리 · 부르는 곳 · 인력 · 재료 줄 꼴(요구서_내역16 1장 5~7 · 2장 1 · 2 · 8). + +- 721-1 단가산출 상세 = [기본 로직 선택]이 둘째 줄 [항목 추가] 왼쪽 +- 721-2 M02 세 곳(기본 로직 선택 · 항목 추가 · 일위대가 [로직 추가])만 새 창 · B09 · 불러오기는 옛 창 +- M01 전체 = 로직 · 인력(job) · 재료(price) · 기계 · 실무 호표 없음 +- 고른 인력 · 재료 = 단가산출 더한줄 `{종류, 요소}` · 일위대가 고정 줄(705-1 이음 꼴) — 둘 다 서버가 받아 노임 · 자재 값으로 셈 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Sangeun_Calc as calc + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +LB, MT = "LB000033", "MT000018" # 석공 · 합판(내수) — M01 에 있음 + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_721_1_단추_이름과_자리(): + detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") + assert "children: [views.root, pick, addButton, baseHint]" in detail + assert " pick,\n );" not in detail # 머리 줄에서 뺌 + locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") + assert 'M02_SangeunPickBase: ["기본 로직 선택"' in locale + assert 'M02_SangeunAddLine: ["항목 추가"' in locale + + +def test_721_2_부르는_곳_셋만_새_창(): + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + labor_pick = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") + assert 'if (lib?.sangeun && lib.layer !== "project")' in labor_pick + assert "return openFindPicker({" in labor_pick + assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음 + assert "logicOnly: true," in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") + assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") + assert "sangeun: true," in _text(M02 / "M02_MasterTemplete_Hopyo_Pick.ts") # 일위대가 + # 옛 창 그대로 — B09 · 불러오기는 sangeun 없이 부름 + for f in ( + ROOT / "B09_Estimation" / "B09_Estimation_UI_Link.ts", + ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts", + M02 / "M02_MasterTemplete_Hopyo_Choose.ts", + M02 / "M02_MasterTemplete_Hopyo_Ledger.ts", + ): + text = _text(f) + assert "openLogicPicker(" in text and "sangeun:" not in text, f.name + # 탭 셋 · 찾기 칸 하나 · 거름 고르기 없음 · M01 = 로직 · 인력 · 재료(기계 · 호표 없음) + for key in ('key: "m01"', 'key: "sk"', 'key: "pre"'): + assert key in find + assert "createSelectField" not in find and "Hopyo" not in find + assert '(kind: "job" | "price")' in find and "/elements" not in find + + +def test_721_2_고정_줄_꼴(): + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + assert "이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }" in find + assert "go: put ? take(() => put(e))" in find + assert "dim: !put," in find # 받는 쪽 없으면 회색 막힘 + + +def _fx(name: str, unit: str, code: str) -> dict: + """`fixedOf` 꼴 — 인력 = 노임 이음 · 재료 = 자재 이음 · 단가 = 고를 때 값(셈은 단가 책)""" + labor_ = code.startswith("LB") + return { + "이름": name, + "규격": "", + "단위": unit, + "단가": {"노무비": 1 if labor_ else 0, "재료비": 0 if labor_ else 1, "경비": 0}, + "이음": {"노임": code if labor_ else None, "자재": None if labor_ else code, "기계": None}, + } + + +def test_721_2_인력_재료_서버가_받아_셈(): + whole = labor.m01.loaded()[1] + wage = whole.get(LB)["값"] + sk = calc.calc_doc( + { + "키": "SK-000001", + "이름": "시험", + "단위": "㎡", + "바탕": None, + "더한줄": [ + {"종류": "인력", "요소": LB, "이름": "석공", "단위": "인", "수량": 1}, + {"종류": "재료", "요소": MT, "이름": "합판", "단위": "㎡", "수량": 1}, + ], + } + )["1단위"] + doc = labor.clean( + { + "담은로직": [ + {"로직": "", "고정": _fx("석공", "인", LB), "수량": {"손": 1}}, + {"로직": "", "고정": _fx("합판", "㎡", MT), "수량": {"손": 1}}, + ] + } + ) + got = labor.calc(doc, {}) + assert sk["노무비"] == pytest.approx(wage) and sk["재료비"] > 0 + assert got["반영합"]["계"] == pytest.approx(sk["계"]) + assert [ln["이은"] for ln in got["줄"]] == [["노무비"], ["재료비"]] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index dc3f2fcb1..4d404a9ba 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -181,6 +181,11 @@ font-weight: var(--font-weight-bold); } +/* 흐린 줄(721-2 · rowClass) — 지금 못 고르는 줄 */ +.ui-data-table__table tbody tr.is-dim td { + color: var(--color-text-muted); +} + .ui-data-table__table tbody tr.is-clickable { cursor: pointer; } diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 7ffe3a975..eb64b3e3a 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -9,7 +9,7 @@ * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 } * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) - * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 + * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 · is-dim 흐림(721-2) * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) * 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index d7edfbbd0..6bb852a5a 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -239,8 +239,11 @@ export const ui_locales_m2 = { M02_SangeunColAmount: ["금액", "Amount"], M02_SangeunSubtotal: ["소계", "subtotal"], M02_SangeunTotal: ["계", "Total"], - M02_PickStd: ["기준 목록", "Standard list"], M02_PickSangeun: ["단가산출", "Unit-cost calcs"], - M02_PickElement: ["M01 요소", "M01 elements"], - M02_PickMaterial: ["재료 고르기", "Pick a material"], + M02_FindTitle: ["찾아 넣기", "Find and add"], + M02_FindSearch: ["이름 · 규격 · 키 · 구분으로 찾기", "Search by name, spec, key or class"], + M02_FindM01: ["M01 전체", "All M01"], + M02_FindClass: ["구분", "Class"], + M02_FindLogic: ["단가산출 로직", "Unit-cost logic"], + M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"], } as const; From e9106ae4d68a44af45888a885e660341d2da80a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:47:26 +0900 Subject: [PATCH 15/23] =?UTF-8?q?fix(720-2):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=B8=B5=20=EC=A4=84=20=E3=80=8C+=E3=80=8D=20=3D=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9E=90=EB=A6=AC(=EC=A4=84=20=EC=98=A4=EB=A5=B8?= =?UTF-8?q?=EC=AA=BD=20=EB=81=9D=20m02-tree=5F=5Ftools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts | 4 +++- resources/tester/test_720_m02_folder_layer.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts index 5bdbad680..7bb16a460 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -143,6 +143,8 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { add.classList.add("m02-tree__add"); add.title = L("M02_TreeAddTop"); add.setAttribute("aria-label", L("M02_TreeAddTop")); + // 구조물 조합 줄과 같은 자리(줄 오른쪽 끝 · `m02-tree__tools`) + const addTools = el("span", { className: "ui-toolbar m02-tree__tools", children: [add] }); const leaves = new Map(); const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 const targets = new Map(); @@ -470,7 +472,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { const mine = ++seq; const at = opt.scope(); add.hidden = !at.canWrite; - barHost.replaceChildren(opt.bar(add)); + barHost.replaceChildren(opt.bar(addTools)); if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); const [got, tree] = await Promise.all([ // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 diff --git a/resources/tester/test_720_m02_folder_layer.py b/resources/tester/test_720_m02_folder_layer.py index d22012be0..511e97ab0 100644 --- a/resources/tester/test_720_m02_folder_layer.py +++ b/resources/tester/test_720_m02_folder_layer.py @@ -64,7 +64,7 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기(): # 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([]) assert page.count(" add,\n") == 3 # 구조물 조합 · 일위대가 · 단가산출 assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib - assert "add.hidden = !at.canWrite;" in lib and "opt.bar(add)" in lib + assert "add.hidden = !at.canWrite;" in lib and "opt.bar(addTools)" in lib assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄 assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([])' in lib # 문서 줄 tree = _text("M02_MasterTemplete_TreePanel.ts") From 7d007e4feb65371134d047d31468c3cfa4415415 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:51:27 +0900 Subject: [PATCH 16/23] =?UTF-8?q?feat(723-1):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80(=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9)=20?= =?UTF-8?q?=EC=95=88=20=EC=A4=84=20=EC=88=98=EB=9F=89=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=95=88=20=ED=95=A8=20=E2=80=94=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=95=88=20=EC=A4=84=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EC=97=B0=EA=B2=B0=20=C2=B7=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=88=98=EB=9F=89=20=3D=20?= =?UTF-8?q?=EB=B3=B8=EB=9C=AC=20=EC=B6=9C=EC=B2=98=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장(`M02_Labor_Ref.no_qty`) = 안 줄 · 자재 수량 늘 null(비율 고정 줄 율만) · 기본 {손: 1} 걷음 · 굳힌 사본도 걷음 - 펼침(`Expand`) = 안 줄 수량 = 쓰는 쪽 given 알약 「참조|층|주인|키#차례」 · 「…#자재|코드」 · 층 원본 옛 손 안 씀 · 굳힌 사본(프로젝트) 손 · 비율 줄 율만 · 없으면 「수량 없음」(1 아님 · 그 일위대가 기준 없음) - 구조물 조합 알약(`pills`) = 참조 줄 + 안 줄 알약(`inner_pills`) — 면적산출 칸에 이음 - 불러오기(`Hopyo_Build`) = 수량 안 들임 · 엑셀 수량 = `본뜬.실무수량`(셈 안 씀 · 엑셀 대조만 · 하위 호표까지) - B08 `_ref_mats` = 안 줄 수량 = 굳힌 손 · 아니면 사본 면적 알약 값 - 낡은 시험 고침(사유 = 수량 저장 안 함 · 이은 값으로 셈): test_m02_labor_ref · test_175_precombo_mats · test_715_3_labor_patch · test_m02_hopyo_build · test_m02_hopyo_flow · test_160_3(굳힌 옛 사본 꼴 손 채움) - 남김: B09 시험 5(test_204_2 · test_703 · test_719_1 · test_719_2) = sub8 723-4 가 B09 코드와 함께 Co-Authored-By: Claude Opus 5.5 --- B08_Quantity/B08_Quantity_Router_Material.py | 31 +++++-- M02_MasterTemplete/M02_Hopyo_Build.py | 27 +++++- M02_MasterTemplete/M02_Labor_Doc.py | 11 +-- M02_MasterTemplete/M02_Labor_Expand.py | 91 ++++++++++++++++--- M02_MasterTemplete/M02_Labor_Ref.py | 30 +++++- .../tester/test_160_3_b08_unmatched_total.py | 3 +- resources/tester/test_175_precombo_mats.py | 63 +++++++++---- resources/tester/test_715_3_labor_patch.py | 6 +- resources/tester/test_m02_hopyo_build.py | 31 ++++--- resources/tester/test_m02_hopyo_flow.py | 6 +- resources/tester/test_m02_labor_ref.py | 49 ++++++---- 11 files changed, 261 insertions(+), 87 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index bed6b5f55..483076076 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -58,11 +58,14 @@ def _qty(value: Any) -> float | None: return _num(float(value) if isinstance(value, Decimal) else value) -def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]: +def _ref_mats( + whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = (), given: dict | None = None +) -> list[tuple]: """참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길: - 승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 · - 사전조합 자재(175-2) = 손 수량(빈칸 = 안 셈 · 품목 키 = 코드 · `자재` 표시 = 연료 거름 안 함) · - 순환 · 깊이 넘음 · 없는 문서 = 빈 것.""" + 승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 안 줄 수량(굳힌 손 · 아니면 면적 알약 + 「참조|…#차례」 값 `given` · 723-1 · 없으면 안 셈) · 로직 줄은 알약 배로 나눔 · + 사전조합 자재(175-2) = 손 · 알약 「…#자재|코드」(없음 = 안 셈 · 품목 키 = 코드 · `자재` 표시 = + 연료 거름 안 함) · 순환 · 깊이 넘음 · 없는 문서 = 빈 것.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -90,17 +93,23 @@ def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> pills = kept if isinstance(kept, list) else combo_store.combo_pills({"담은로직": lines})["물량"] by_logic = {code: pill for pill in pills for code in pill.get("로직") or []} out: list[tuple] = [] - for one in lines: + given = given or {} + + def qty_of(one: dict, key: str) -> float | None: # 723-1 — 굳힌 손 · 아니면 이은 칸 값 + hand = _qty((one.get("수량") or {}).get("손")) + return _qty(given.get(f"{rid}#{key}")) if hand is None else hand + + for at, one in enumerate(lines): if isinstance(one.get("참조"), dict): - sub = _ref_mats(whole, read, one["참조"], (*trail, rid)) + sub = _ref_mats(whole, read, one["참조"], (*trail, rid), given) elif one.get("로직"): sub = run(one, (by_logic.get(one["로직"]) or {}).get("배")) else: continue - hand = _qty((one.get("수량") or {}).get("손")) - out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub] + if (hand := qty_of(one, str(at))) is not None: + out += [(m, q * hand) for m, q in sub] for mat in doc.get("자재") or []: - if hand := _qty((mat.get("수량") or {}).get("손")): + if hand := qty_of(mat, f"자재|{mat.get('코드')}"): line = {k: mat.get(k) for k in ("이름", "단위")} out.append(({**line, "품목": {"키": mat.get("코드")}, "자재": True}, hand)) return out @@ -111,6 +120,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[ (키 = 고른 품목 MT 코드 · 없으면 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) + 참조 줄 안 재료 (160-3) · 줄 수량 빈칸 · 0 · 파형강관 · 기계 연료는 안 담음 · 셈 못 하면 빈 것.""" from M02_MasterTemplete import M02_Item_Copy as copies + from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete.M02_Item_Prices import load_prices from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json @@ -149,6 +159,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[ frozen = (labor.get("참조사본") if isinstance(labor, dict) else None) or {} whole = None # 사본 단가 — 참조 줄이 있을 때만 읽음 + given = labor_doc.area_values(read_json(copies.folder_of(root, folder) / copies.KINDS["면적"])) def read(ref: dict) -> Any: return frozen.get(refs.ref_id(ref)) @@ -159,7 +170,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[ continue if isinstance(line.get("참조"), dict): # 참조 줄 = 배 1 whole = load_prices(bundle) if whole is None else whole - for mat, per in _ref_mats(whole, read, line["참조"]): + for mat, per in _ref_mats(whole, read, line["참조"], given=given): logic_mat(mat, per * qty) continue for mat in line.get("재료") or []: diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 38aaf6f1a..a34230d62 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -25,6 +25,8 @@ 통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) · `줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). +수량(723-1 · 내역 17-1) = 저장 때 걷음(`refs.no_qty`) · 엑셀 수량은 `본뜬.실무수량 {줄: {차례: 수}, 자재: {코드: 수}}` +(출처 기록 · 셈 안 씀 · 엑셀 대조 `compare` 만 — 하위 호표 것까지 `excel_given`). """ from __future__ import annotations @@ -90,7 +92,12 @@ def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]: - """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키.""" + """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키 · 수량 걷음(723-1).""" + hands = { + "줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")}, + "자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")}, + } + doc = {**refs.no_qty(doc), "본뜬": {**doc["본뜬"], "실무수량": hands}} at = refs.here() want = doc["본뜬"] have = next( @@ -411,10 +418,24 @@ def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None return sum(v.to_integral_value(how) for v in parts.values()) +def excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]: + """`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용.""" + doc = refs.read_ref(ref) if depth <= refs.DEPTH else None + if not isinstance(doc, dict): + return {} + rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {} + out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()} + out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()} + for one in refs.refs_in(doc): + out |= excel_given(one, depth + 1) + return out + + def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · - 반올림 중 엑셀과 같은 쪽 · `_excel_round`).""" - got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) + 반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1).""" + head = {k: ref[k] for k in ("층", "주인", "키")} + got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head) base = got.get("기준") or {} excel = (hopyo.get("금액") or {}).get("계") cuts = [_excel_round(got, *one) for one in CUTS] if base else [] diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 71d496dd0..e975e76aa 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -12,8 +12,8 @@ 기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). 켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율 (`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수). -참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) · -분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. +참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand` · 안 줄 +수량 = 같은 given 의 안 줄 알약 · 723-1) · 분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. 규격 열(704-4) = 문서 `규격?` 칸 · 열마다 줄 수량(`M02_Labor_Spec` · settle `col`) · 계산값 `규격합`. @@ -354,7 +354,7 @@ def pills(doc: dict) -> list[dict[str, Any]]: """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) + 자재 알약 「자재|MT키」.""" logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] - logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)] + logic += [p for one in doc.get("담은로직") or [] if _is_ref(one) for p in refs.pills_of(one["참조"])] # fmt: skip mats = [ { "키": material_key(m["코드"]), @@ -507,8 +507,8 @@ def calc( 프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) · `read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층.""" - whole = m01.loaded()[1] if whole is None else whole - expand = refs.Expand(whole) if read is None else refs.Expand(whole, read) + whole, have = m01.loaded()[1] if whole is None else whole, _area_of(doc.get("열")) if given is None else given # fmt: skip + expand = refs.Expand(whole, read or refs.read_ref, have) # 사전조합 안 줄 수량 = 알약 값(723-1) if pills is None: pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] by_logic = {code: pill for pill in pills for code in pill.get("로직") or []} @@ -541,7 +541,6 @@ def calc( } 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_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 10054f5c7..0f18a8798 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -2,6 +2,9 @@ 참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 단가승격(옛 SP) · 사전조합(SC) · 단가산출(SK · `M02_Sangeun_Calc.unit`). 이름은 `M02_Labor_Ref.Expand` 로 그대로 부름. +사전조합 안 줄 수량(723-1 · 내역 17-1) = 쓰는 쪽 `given` 의 알약 「참조|층|주인|키#차례」(자재 = +「…#자재|코드」 · `inner_pills`) · 문서 손은 굳힌 사본(`hands` · 프로젝트 · 불러오기 대조)과 비율 줄(율)만 · +없으면 「수량 없음」(1 아님 · 그 사전조합 기준 없음). """ from __future__ import annotations @@ -16,14 +19,35 @@ from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as calc Ref = dict[str, Any] +NO_QTY = "수량 없음" +MAT = "자재|" class Expand: """참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 · 깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본).""" - def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = refs.read_ref) -> None: + def __init__( + self, + whole: Any, + read: Callable[[Ref], dict | None] = refs.read_ref, + given: dict[str, Any] | None = None, + hands: bool | None = None, + ) -> None: self.whole, self.read, self.memo = whole, read, {} + self.given = given or {} + self.hands = ( + read is not refs.read_ref if hands is None else hands + ) # 층 원본 손 = 옛 값(안 씀) + + def _qty(self, one: dict, key: str) -> Decimal | None: + """안 줄 수량 — 손(굳힌 사본 · 비율 줄) · 알약 값(given) · 없음.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + hand = labor._number((one.get("수량") or {}).get("손")) + if hand is not None and (self.hands or refs.is_rate(one)): + return hand + return labor._number(self.given.get(key)) def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]: rid = refs.ref_id(ref) @@ -110,8 +134,8 @@ class Expand: for pill in pills or combo_store.combo_pills({"담은로직": lines})["물량"] for code in pill["로직"] } - inner, rows, why = [], [], None - for one in lines: + inner, rows, why, rid = [], [], None, trail[-1] if trail else "" + for at, one in enumerate(lines): ref = one.get("참조") if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈 head = {"참조": ref} @@ -122,18 +146,21 @@ class Expand: head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"]) else: continue - qty = labor._number((one.get("수량") or {}).get("손")) - qty = Decimal(1) if qty is None else qty + qty = self._qty(one, f"{rid}#{at}") keep = ("이름", "단위", "기준", "까닭") inner.append({**head, "수량": qty, **{k: sub.get(k) for k in keep}}) if sub.get("기준") is None: why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}" continue - rows.append({k: v * qty for k, v in sub["기준"].items()}) - for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈 - qty = labor._number((mat.get("수량") or {}).get("손")) - if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) + if qty is None: # 723-1 — 이은 값 없음 = 금액 빠짐(1 로 안 셈) + inner[-1]["까닭"] = NO_QTY + why = why or f"「{sub.get('이름')}」 {NO_QTY}" continue + rows.append({k: v * qty for k, v in sub["기준"].items()}) + for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 수량(이은 값) + if labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) + continue + qty = self._qty(mat, f"{rid}#{MAT}{mat['코드']}") got = labor.material_price(self.whole, mat["코드"], mat.get("단위")) price = labor._number(got["기준단가"]) zero = dict.fromkeys(labor.COSTS, Decimal(0)) @@ -149,8 +176,9 @@ class Expand: "까닭": got["까닭"], } ) - if base is None: - why = why or f"「{name}」 {got['까닭']}" + if qty is None or base is None: + inner[-1]["까닭"] = got["까닭"] or NO_QTY # 단가 까닭 먼저 + why = why or f"「{name}」 {inner[-1]['까닭']}" continue rows.append({k: v * qty for k, v in base.items()}) under = labor._sum(rows) @@ -172,3 +200,44 @@ def head_of(doc: dict) -> str | None: """상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None.""" unit = str(doc.get("단위") or "").strip() return f"1 {unit} 당" if unit else None + + +def inner_pills( + ref: Ref, read: Callable[[Ref], dict | None] = refs.read_ref, depth: int = 1 +) -> list[dict[str, Any]]: + """사전조합 안 줄 알약(723-1) — 키 「참조|…#차례」 · 「참조|…#자재|코드」 · 이름 = 「일위대가 · 줄」 · + 단위 = 그 줄 분모 · 비율 줄 뺌 · 안의 사전조합도 한 번씩(깊이 상한) · 사전조합 아니면 빈 것.""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + + doc = read(ref) if refs.kind_of(ref["키"]) == "precombo" and depth <= refs.DEPTH else None + if not isinstance(doc, dict): + return [] + rid, top = refs.ref_id(ref), doc.get("이름") or ref["키"] + lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + by_logic = { + code: pill + for pill in combo_store.combo_pills({"담은로직": lines})["물량"] + for code in pill["로직"] + } + out: list[dict[str, Any]] = [] + for at, one in enumerate(lines): + if refs.is_rate(one): + continue + sub = one.get("참조") + if isinstance(sub, dict): + got = read(sub) or {} + name, unit = got.get("이름") or sub["키"], got.get("단위") + out += inner_pills(sub, read, depth + 1) + elif fixed.is_fixed(one): + name, unit = one["고정"].get("이름"), one["고정"].get("단위") + else: + pill = by_logic.get(one.get("로직")) or {} + name, unit = pill.get("이름") or one.get("로직"), pill.get("단위") + name = one.get("별칭") or name + out.append({"키": f"{rid}#{at}", "이름": f"{top} · {name}", "단위": unit or "", "로직": [], "참조": ref}) # fmt: skip + for mat in doc.get("자재") or []: + name = mat.get("이름") or mat.get("코드") + key = f"{rid}#{MAT}{mat.get('코드')}" + out.append({"키": key, "이름": f"{top} · {name}", "단위": mat.get("단위") or "", "로직": [], "참조": ref}) # fmt: skip + seen: set[str] = set() + return [p for p in out if not (p["키"] in seen or seen.add(p["키"]))] diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index b474e7b66..ced3611f8 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -7,9 +7,9 @@ precombo `{층}/precombo/SC-######.json` 사전조합 `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모} | {로직, 입력값, 메모, 수량:{손}, 고름?, 자재표시?}], 자재:[일위대가 자재 꼴]}` — - 승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) · - 자재 수량은 손 · 빈칸 = 안 셈(175) · - 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손 + 승격 · 사전조합 · 단가산출 로직(51-11) · 안 줄 · 자재 수량 = 저장 안 함(늘 null · + 723-1 · 내역 17-1 · `no_qty` · 비율 줄 율만 남김) · 쓰는 쪽 알약으로 이음(`Expand` given) · + 값 = Σ 안 줄 1 단위 값 × 이은 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 이은 수량 sangeun `{층}/sangeun/SK-######.json` 단가산출(715-2) — 꼴 `M02_Sangeun_Doc` · 셈 `M02_Sangeun_Calc` · 참조 자리 = `바탕.참조` · `더한줄[].참조`(`refs_in`) 참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC · SK) · @@ -170,7 +170,19 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None - return out + return no_qty(out) + + +def is_rate(one: Any) -> bool: + """비율 고정 줄(705-3) — 수량 = 율(%) · 기준단위 당 수량 아님.""" + return isinstance(one, dict) and bool((one.get("고정") or {}).get("비율")) + + +def no_qty(doc: dict[str, Any]) -> dict[str, Any]: + """사전조합 안 줄 · 자재 수량 걷음(723-1 · 내역 17-1 = M02 에 저장 안 함) · 비율 줄 율만 남김.""" + lines = [one if is_rate(one) else {**one, "수량": None} for one in doc.get("담은로직") or []] + mats = [{**one, "수량": None} for one in doc.get("자재") or []] + return {**doc, "담은로직": lines, "자재": mats} _OLD_CLASS = re.compile(r"품셈") # 134-5 — 옛 품셈 분류(건설품셈 공통 · 산림품셈 · 07장 …) @@ -234,7 +246,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) - lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) + lines.append({**head, "수량": one["수량"], "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} for one in lines: @@ -458,9 +470,17 @@ def with_pills(doc: dict[str, Any]) -> dict[str, Any]: from M01_MasterData import M01_MasterData_Store_Combo as combo_store lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + doc = no_qty({**doc, "담은로직": lines}) # 723-1 — 옛 손도 굳히지 않음(쓰는 쪽 알약으로) return {**doc, "알약": combo_store.combo_pills({"담은로직": lines})["물량"]} +def pills_of(ref: Ref) -> list[dict[str, Any]]: + """참조 줄 알약 + 사전조합 안 줄 알약(723-1 · `inner_pills`).""" + from M02_MasterTemplete.M02_Labor_Expand import inner_pills + + return [pill(ref), *inner_pills(ref)] + + def frozen_materials(copies: dict[str, Any]) -> list[str]: """굳힌 사전조합의 자재 · 고른 로직 자재 코드(175-2) — 프로젝트 단가 묶음에 넣음.""" out = [] diff --git a/resources/tester/test_160_3_b08_unmatched_total.py b/resources/tester/test_160_3_b08_unmatched_total.py index eacc1d617..647ba7a4d 100644 --- a/resources/tester/test_160_3_b08_unmatched_total.py +++ b/resources/tester/test_160_3_b08_unmatched_total.py @@ -96,7 +96,8 @@ def test_참조_줄_재료_연료_뺌_고른_품목(tmp_path, monkeypatch): price = {"묶음": {"줄": [{"키": "MT9", "이름": "레디믹스트 레미콘", "규격": "25-18-80"}]}} frozen = { ref_id(SC): { - "담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP}], + # 굳힌 옛 사본 = 안 줄 손 다 있음(723-1 앞 · 빈 수량 = 이은 칸 값 · 없으면 안 셈) + "담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP, "수량": {"손": 1}}], "알약": [{"로직": ["L1"], "배": 10}], }, ref_id(SP): {"담은로직": [{"로직": "L2"}], "배": 4}, diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 3688f2103..2713c1f34 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -2,7 +2,7 @@ 175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가 고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400). -175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄 +175-2 셈 — 담은 자재(수량 = 이은 알약 값 · 723-1 · 손은 저장 안 함)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄 재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감. """ @@ -16,7 +16,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store -from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture +from resources.tester.test_m02_labor_ref import _area, world # noqa: F401 — 임시 층 fixture HERE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") @@ -51,7 +51,7 @@ def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811 _, got = _save(world["system"], {"담은로직": [line], "자재": mats}) one = got["담은로직"][0] assert one["고름"] == {"4": NET_ITEM} and one["자재표시"] == {"3": {"자재구분": "관급"}} - assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, {"손": 2}), (WIRE, None)] + assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, None), (WIRE, None)] # 723-1 assert got["자재"][0]["운반비"] == "미포함" and got["자재"][0]["이름"] != "틀린 이름" # 자재 없음 = 빈 목록(옛 문서 꼴 그대로 읽힘) _, empty = _save(world["system"], {"담은로직": [{"로직": NET}]}) @@ -73,8 +73,23 @@ def _price(code): return Decimal(str(labor.material_price(whole, code, None)["기준단가"])) +def _given(key): + """안 줄 알약 값(723-1) — STONE 1 · NET 0 · MAT 2 · WIRE 0.""" + rid = refs.ref_id({**SYS, "키": key}) + return {f"{rid}#0": 1, f"{rid}#1": 0, f"{rid}#자재|{MAT}": 2, f"{rid}#자재|{WIRE}": 0} + + +def _legacy(world, key): # noqa: F811 + """옛 손 수량 문서(723-1 앞 · 굳힌 사본 꼴) — B08 · B09 셈 길 시험용 · 파일은 안 바꿈.""" + doc = json.loads((world["system"] / "precombo" / f"{key}.json").read_bytes()) + hands = ({"손": 1}, {"손": 0}) + lines = [{**one, "수량": q} for one, q in zip(doc["담은로직"], hands)] + mats = [{**m, "수량": {"손": 2} if m["코드"] == MAT else None} for m in doc["자재"]] + return {**doc, "담은로직": lines, "자재": mats} + + def _world_combo(world): # noqa: F811 - """STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값).""" + """STONE 1 + MAT 2 + WIRE 0 · NET 고름 4 = NET_ITEM — (키, 1 단위 값) · 수량은 `_given`.""" lines = [ {"로직": STONE, "수량": {"손": 1}}, {"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}}, @@ -95,34 +110,42 @@ def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(w doc = labor.clean( {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]} ) - one = labor.calc(doc, {})["줄"][0] + bare = labor.calc(doc, {})["줄"][0] + one = labor.calc(doc, _given(key))["줄"][0] + assert bare["기준"] is None and "수량 없음" in bare["까닭"] # 723-1 — 안 이으면 값 없음 assert one["기준"]["계"] == pytest.approx(float(want)) assert one["반영"]["계"] == pytest.approx(float(want * 3)) mats = [x for x in one["안"] if x.get("자재")] - assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈 + assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE = 기본 별도계상(안 셈) assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT))) - # 목록 기준금액(51-4)도 같은 펼침 + # 목록 기준금액(51-4) — 이은 값 없음 = 빈 값(723-1) rows = [{"종류": "precombo", "이름": key}] refs.summaries(world["system"], rows) - assert rows[0]["기준금액"] == pytest.approx(float(want)) + assert rows[0]["기준금액"] is None def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811 from B08_Quantity.B08_Quantity_Router_Material import _ref_mats key, _ = _world_combo(world) - with store.at(world["system"]): - outer = refs.create( - "precombo", - { - "이름": "겹", - "단위": "㎡", - "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}], - }, - )["이름"] - got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) + outer = { + "이름": "겹", + "단위": "㎡", + "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}], + } + docs = {"SC-겹": outer, key: _legacy(world, key)} # 옛 손 문서(723-1 앞 꼴 · B08 길만 잼) + got = _ref_mats(m01.loaded()[1], lambda r: docs.get(r["키"]), {**SYS, "키": "SC-겹"}) mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음 + # 723-1 — 수량 없는 새 문서 = 면적 알약 값(given)으로 · 안 이으면 안 셈 + live = {"SC-겹": outer, key: refs.read_ref({**SYS, "키": key})} + bare = _ref_mats(m01.loaded()[1], lambda r: live.get(r["키"]), {**SYS, "키": "SC-겹"}) + assert [m for m, _ in bare if m.get("자재")] == [] + got = _ref_mats( + m01.loaded()[1], lambda r: live.get(r["키"]), {**SYS, "키": "SC-겹"}, given=_given(key) + ) + mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] + assert mats == [(MAT, pytest.approx(6.0))] def test_B09_겹_대가에_자재_줄(world): # noqa: F811 @@ -131,7 +154,7 @@ def test_B09_겹_대가에_자재_줄(world): # noqa: F811 key, _ = _world_combo(world) ref = {**SYS, "키": key} build = Builder() - bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {}) + bid = build.precombo(m01.loaded()[1], ref, _legacy(world, key), {}) # 옛 손 문서(723-1 앞) mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"] assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)] assert mats[0]["name"] and mats[0]["unit"] @@ -150,5 +173,5 @@ def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(wo assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read() bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats)) (world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본 - got = prices.calc(rows, bundle, {}, {}, None)["줄"][0] + got = prices.calc(rows, bundle, {}, {}, _area(_given(key)))["줄"][0] # 사본 면적 칸 = 안 줄 값 assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9) diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py index b6b4de67a..1ac5bdf78 100644 --- a/resources/tester/test_715_3_labor_patch.py +++ b/resources/tester/test_715_3_labor_patch.py @@ -32,8 +32,12 @@ def _sc(folder, lines, unit="㎡"): def _value(folder, key): + """안 줄 수량 = 모두 1(723-1 · 저장 안 함 → 쓰는 쪽 알약 값).""" + ref = {**MINE, "키": key} with store.at(folder): - return refs.Expand(_whole())({**MINE, "키": key}) + lines = refs.read_ref(ref)["담은로직"] + given = {f"{refs.ref_id(ref)}#{i}": 1 for i in range(len(lines))} + return refs.Expand(_whole(), given=given)(ref) def test_logic_line_cover_in_precombo(world): diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 881a5fb7e..d6e0bd709 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -61,22 +61,24 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"] stone, *made, soil = doc["담은로직"] # 705-3 — 확인 안 한 줄 · 「우리에 없음」 = 고정 줄 assert stone["고정"]["단가"] == {"노무비": 0, "재료비": 1400000, "경비": 0} - assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] == {"손": 1} - assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] == {"손": 0.09} + hands = origin["실무수량"]["줄"] # 723-1 — 수량 = 저장 안 함 · 엑셀 수량은 출처 기록만 + assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] is None and hands["0"] == 1 + assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] is None + assert hands[str(len(doc["담은로직"]) - 1)] == 0.09 assert origin["품셈"] == [1, 2] and got["줄수"]["고정"] == 2 and got["빠진줄"] == [] assert made == [ { "로직": "GF000101", "입력값": {"지역": "서울"}, "별칭": "구조물터파기(인력100%)", - "수량": {"손": 0.14}, + "수량": None, # 723-1 — 엑셀 0.14 = 본뜬.실무수량 "메모": "실무 0.14㎥ × 1 · 토사", }, { "로직": "GF000113", "입력값": {}, "별칭": "구조물되메우기(인력100%)", - "수량": {"손": 0.1}, # 엑셀 0.05 × 배 2 + "수량": None, # 엑셀 0.05 × 배 2 = 본뜬.실무수량(723-1) "메모": "실무 0.05㎥ × 2", }, ] @@ -108,14 +110,20 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): first, second, fx, wrapped = doc["담은로직"] assert fx["고정"]["출처"]["줄"] == 2 # 확인 안 한 줄 = 고정(705-3) assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭 - assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사" - assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09} + hands = doc["본뜬"]["실무수량"]["줄"] # 723-1 — 저장 수량 없음 · 엑셀 수량 = 출처 기록 + assert second["수량"] is None and hands["1"] == 0.014 + assert second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사" + assert ( + wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] is None and hands["3"] == 0.09 + ) inner = _doc(mine, wrapped["참조"]["키"]) assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄 GF000101" - assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1} + assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] is None + assert inner["본뜬"]["실무수량"]["줄"] == {"0": 1} assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [ - ("MT004258", {"손": 1}, "직접계산") + ("MT004258", None, "직접계산") ] + assert doc["본뜬"]["실무수량"]["자재"] == {"MT004258": 1} stone = next(v for k, v in _book(mine).items() if k.startswith("임도표지석(받침")) assert stone["짝"][0]["자재"] == "MT004258" files = sorted(p.name for p in (mine / "precombo").glob("*.json")) @@ -232,14 +240,15 @@ def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): assert rate["비율"] == {"기준": "재료비", "비목": "재료비"} got = _build(client, 45, ref) mine = layers.personal_dir(7, 42) - line = _doc(mine, got["참조"]["키"])["담은로직"][0] + made = _doc(mine, got["참조"]["키"]) + line = made["담은로직"][0] assert line["참조"] == child["참조"] and line["별칭"] == "고화매트재료비" - assert line["수량"] == {"손": 2.3} + assert line["수량"] is None and made["본뜬"]["실무수량"]["줄"]["0"] == 2.3 # 723-1 assert "고화매트재료비||m2" not in _book(mine) # 호표 참조 줄 = 장부 안 씀 whole = _build(client, 3, 통째={"대상": [{"로직": "GF000101", "배": 1}]}) doc = _doc(mine, whole["참조"]["키"]) assert [x["로직"] for x in doc["담은로직"]] == ["GF000101"] - assert doc["담은로직"][0]["수량"] == {"손": 1} + assert doc["담은로직"][0]["수량"] is None and doc["본뜬"]["실무수량"]["줄"] == {"0": 1} assert {x["까닭"] for x in whole["빠진줄"]} == {"호표 통째로 대신"} head = next(v for k, v in _book(mine).items() if k.startswith("호표|임도표지석|")) assert head["짝"][0]["로직"] == "GF000101" and head["원래"]["품명"] == "임도표지석" diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index 3e79e1655..cdb995510 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -128,8 +128,10 @@ def test_섞인_줄_몫_배(client): assert screen["lines"][0]["checkable"] and not screen["lines"][0]["checked"] one = {k: part[k] for k in ("로직", "입력값", "배", "몫")} made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}]) - row = next(x for x in _doc(made["참조"])["담은로직"] if x.get("로직") == "GF000211") - assert row["수량"]["손"] == round(line["수량"] * 0.1, 6) + doc = _doc(made["참조"]) + at = next(i for i, x in enumerate(doc["담은로직"]) if x.get("로직") == "GF000211") + assert doc["담은로직"][at]["수량"] is None # 723-1 — 수량 저장 안 함 · 엑셀 수량 = 출처 기록 + assert doc["본뜬"]["실무수량"]["줄"][str(at)] == round(line["수량"] * 0.1, 6) def test_막는_길(client): diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 144178dde..b3534e2ef 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -73,6 +73,13 @@ def _combo(folder, lines, name="조합"): return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"] +def _area(given): + """면적산출 사본 꼴 — 알약 키마다 칸 하나(723-1 · 안 줄 수량 = 이은 칸 값).""" + cells = {f"A{i}": {"값": v} for i, v in enumerate(given.values(), 1)} + links = [{"칸": f"A{i}", "키": k} for i, k in enumerate(given, 1)] + return {"시트": [{"id": "s", "칸": cells, "연결": links}]} + + def _read(folder, kind, key): return json.loads((folder / kind / f"{key}.json").read_bytes()) @@ -221,7 +228,9 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 ref = {**SYS, "키": sc} with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]}) - got = labor.calc(doc, {}) + bare = labor.calc(doc, {})["줄"][0] # 723-1 — 안 줄 손은 저장 안 함 · 안 이으면 수량 없음 + got = labor.calc(doc, {f"{refs.ref_id(ref)}#0": 2}) # 안 줄 알약 = 구조물 조합 칸 값 + assert bare["기준"] is None and "수량 없음" in bare["까닭"] and bare["안"][0]["수량"] is None one = got["줄"][0] want = Decimal(str(_base(TEN)["계"])) / 10 * 2 assert one["기준"]["계"] == pytest.approx(float(want)) @@ -233,7 +242,9 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) - assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}] + assert pills[0] == {"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref} + assert [p["키"] for p in pills[1:]] == [f"{refs.ref_id(ref)}#0"] # 안 줄 알약(723-1) + assert pills[1]["이름"].startswith("둘 · ") def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): @@ -386,9 +397,10 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_ sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}]) ref = {**SYS, "키": sc} + given = {f"{refs.ref_id(ref)}#0": 2} # 723-1 — 안 줄 수량 = 이은 칸 값 with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]}) - live = labor.calc(doc, {})["줄"][0]["기준"]["계"] + live = labor.calc(doc, given)["줄"][0]["기준"]["계"] rows = source.labor_doc_rows(doc) assert set(rows["참조사본"]) == {refs.ref_id(ref), refs.ref_id({**SYS, "키": sp})} assert refs.frozen_logics(rows["참조사본"]) == [TEN] @@ -396,7 +408,7 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_ for key in (sc, sp): # 원본이 사라져도 프로젝트는 사본만 봄 kind = refs.kind_of(key) (world["system"] / kind / f"{key}.json").unlink() - got = prices.calc(rows, bundle, {}, {}, None) + got = prices.calc(rows, bundle, {}, {}, _area(given)) line = got["줄"][0] assert not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1) assert line["기준"]["계"] == pytest.approx(live, rel=1e-9) @@ -438,8 +450,8 @@ def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋") rows = [{"종류": "precombo", "이름": sc}, {"종류": "labor", "이름": "rv"}] refs.summaries(world["system"], rows) - one = float(Decimal(str(_base(TEN)["계"])) / 10) - assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", pytest.approx(one * 3)) + # 723-1 — 안 줄 수량 저장 안 함 → 목록 기준금액 없음(쓰는 쪽 알약으로만 값) + assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", None) assert "기준금액" not in rows[1] @@ -456,16 +468,17 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world ] sc = _combo(world["system"], lines, "섞음") saved = _read(world["system"], "precombo", sc)["담은로직"] - assert saved[1:] == [ - {"로직": STONE, "입력값": {}, "수량": {"손": 3}, "메모": "돌"}, - {"로직": TEN, "입력값": {}, "수량": {"손": 1}, "메모": None}, + assert saved[1:] == [ # 723-1 — 안 줄 수량 저장 안 함 + {"로직": STONE, "입력값": {}, "수량": None, "메모": "돌"}, + {"로직": TEN, "입력값": {}, "수량": None, "메모": None}, ] ten, stone = (Decimal(str(_base(c)["계"])) for c in (TEN, STONE)) - want = ten / 10 * 2 + stone * 3 + ten / 10 * 1 # 손셈 — 승격 · TEN 은 원/10㎡(배 10) + want = ten / 10 * 2 + stone * 3 + ten / 10 * 1 # 이은 수량 — 승격 · TEN 은 원/10㎡(배 10) ref = {**SYS, "키": sc} + given = {f"{refs.ref_id(ref)}#{i}": q for i, q in enumerate((2, 3, 1))} with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]}) - one = labor.calc(doc, {})["줄"][0] + one = labor.calc(doc, given)["줄"][0] assert one["기준"]["계"] == pytest.approx(float(want)) assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert [x.get("로직") or x["참조"]["키"] for x in one["안"]] == [sp, STONE, TEN] @@ -482,16 +495,16 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world bundle = prices.to_json(prices.collect([TEN, STONE])) for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값 (world["system"] / refs.kind_of(key) / f"{key}.json").unlink() - got = prices.calc(rows, bundle, {}, {}, None)["줄"][0] + got = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0] assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9) -def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world): +def test_사전조합이_자기_자신을_담으면_422_수량_없음(world): sp = _promote(world["system"]) a = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "가") with store.at(world["system"]): have = store.read("precombo", a) - assert have["문서"]["담은로직"][0]["수량"] == {"손": 1} + assert have["문서"]["담은로직"][0]["수량"] is None # 723-1 — 기본 1 걷음 with pytest.raises(store.StoreError) as e: store.write( "precombo", @@ -535,14 +548,16 @@ def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, mon with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]}) rows = source.labor_doc_rows(doc) + given = {f"{refs.ref_id(ref)}#0": 1} kept = rows["참조사본"][refs.ref_id(ref)]["알약"] assert [(p["로직"], p.get("배")) for p in kept] == [([TEN], 10)] assert "알약" not in _read(world["system"], "precombo", sc) # 층 문서는 그대로 bundle = prices.to_json(prices.collect([TEN])) - before = prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] + before = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0]["기준"]["계"] changed = {"물량": [{"키": "x|㎡", "이름": "x", "단위": "㎡", "로직": [TEN]}]} # 배 1 로 바뀜 monkeypatch.setattr(combo_store, "combo_pills", lambda row: changed) - assert prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] == pytest.approx(before) + again = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0]["기준"]["계"] + assert again == pytest.approx(before) with store.at(world["system"]): - live = labor.calc(doc, {})["줄"][0]["기준"]["계"] + live = labor.calc(doc, given)["줄"][0]["기준"]["계"] assert live == pytest.approx(before * 10) # 층 셈은 지금 M01(배 1) From cf41e303d0aafac026046f5f3594d8f24eb868a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:55:12 +0900 Subject: [PATCH 17/23] =?UTF-8?q?feat(723-4):=20B09=20=EC=82=AC=EB=B3=B8?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80(SC)=20=EC=95=88=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=EB=9F=89=20=3D=20=EA=B5=B3=ED=9E=8C=20?= =?UTF-8?q?=EC=86=90=20=E2=86=92=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=EB=A9=B4=EC=A0=81=20=EC=95=8C=EC=95=BD=20=EA=B0=92?= =?UTF-8?q?=20=E2=86=92=20=EC=97=86=EC=9D=8C(=EB=B9=88=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=20=EA=B3=B5=EC=82=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor_Source.precombo = 빈 손 1 로 셈 걷음 · 안 줄 · 자재 수량 = 굳힌 옛 손 → 쓰는 구조물 사본 면적 알약 값 (723-1 「참조 id#차례」 · 「참조 id#자재|코드」 · 짝 = M02_Labor_Expand._qty 차례) → 없음 = qty null · `ask` - 알약 · 없음 쓴 SC 블록 덮개 열쇠 = `C:참조|S:사본` · `C:참조|L:서고`(쓰는 곳별 · 옛 손뿐 = `C:참조` 그대로) - 셈 짝(Calc_Labor .py ↔ .ts) = `ask` 줄 수량 비면 비고 · 연결 없음 「수량 없음 — 이 공사에서 입력」 · 702 덮개 qty 입력 뒤 금액 · 글 없음 - 지킴 시험 test_723_4_b09_sc_qty(경고 · 입력 금액 · 알약 굳힘 · 옛 손 · 거울) - 기존 공사 금액 전후 같음(검증_랩탑메인 · 검증_sub8 B09 셈 답 통째 같음) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 13 ++- B09_Estimation/B09_Estimation_Calc_Labor.ts | 8 +- B09_Estimation/B09_Estimation_Labor_Source.py | 81 ++++++++++---- resources/tester/test_723_4_b09_sc_qty.py | 105 ++++++++++++++++++ 4 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 resources/tester/test_723_4_b09_sc_qty.py diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 3b4ef0cd0..97e3c01cf 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -18,7 +18,7 @@ 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. 빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps). 빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김 · - 줄 0 개 블록 = 빈 줄 하나(`NO_LINES`). + 줄 0 개 블록 = 빈 줄 하나(`NO_LINES`) · 줄 `ask`(723-4 SC 안 줄 수량 없음) = 수량이 비면 `why`. 고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} — 있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) · 짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만. @@ -131,10 +131,15 @@ def _block( for part, p in prices.items() } ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음 - lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts, **extra}) + ask = {"why": line["ask"]} if qty is None and line.get("ask") else {} # 723-4 + lines.append( + {**line, **ref, **ask, "qty": qty, "prices": prices, "amounts": amounts, **extra} + ) if qty is None or None in prices.values(): - why = line.get("why") or ( - "수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음") + why = ( + line.get("why") + or ask.get("why") + or ("수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음")) ) gaps.append(gap(TAB, block["id"], f"{block['name']} · {line.get('name') or ''}", why)) for line in block["lines"]: diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index e23923f07..54f5d4855 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -15,7 +15,7 @@ * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) * 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps) * 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 · - * 줄 0 개 블록 = 빈 줄 하나(NO_LINES) + * 줄 0 개 블록 = 빈 줄 하나(NO_LINES) · 줄 ask(723-4 SC 안 줄 수량 없음) = 수량이 비면 why * 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} — * 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만) * 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 @@ -123,9 +123,11 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: amounts[part] = p === null || qty === null ? null : rounddown(p * qty, 1); } const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음 - lines.push({ ...line, ...ref, qty, prices, amounts, ...extra }); + const ask: Row = qty === null && line.ask ? { why: line.ask } : {}; // 723-4 + lines.push({ ...line, ...ref, ...ask, qty, prices, amounts, ...extra }); if (qty === null || PARTS.some((part) => prices[part] === null)) { - const why = line.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음")); + const why = + line.why || ask.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음")); gaps.push(gap(TAB, b.id, `${b.name} · ${line.name || ""}`, why)); } } diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 57da1420b..5dd10e815 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -4,8 +4,11 @@ (`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침: 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 - 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) · - 자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음) + 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄 · 자재 줄(175-2) · + 안 줄 수량(723-4) = 굳힌 옛 손 → 쓰는 구조물 사본 면적 알약 값(723-1 + 「참조 id#차례」 · 「참조 id#자재|코드」) → 없음 = 빈 줄(`ask` · 이 공사에서 입력 = + 702 덮개 qty) · + 알약 · 없음 쓴 블록 덮개 = `C:참조|S:사본` · `C:참조|L:서고`(옛 손뿐 = `C:참조`) 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) @@ -56,6 +59,7 @@ BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*") BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*") BASE_LABOR = re.compile(r"^\s*\(.*\*\s*LB\d+") AREA_FILE = "면적.json" +ASK = "수량 없음 — 이 공사에서 입력" # 723-4 SC 안 줄 수량 빈칸(펼침 품 · 수량 칸) def _f(value: Any) -> float | None: @@ -253,10 +257,18 @@ class Builder: # ── 조합 · 사전조합 줄 ────────────────────────────────────────────── def line_of( - self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0 + self, + whole: Any, + one: dict, + qty: Any, + times: Any, + name: str, + copies: dict, + depth: int = 0, + use: tuple[dict, str] = ({}, ""), ) -> dict[str, Any]: """담은 로직 줄 하나 → 블록 줄 — 대가 줄 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫) · - 산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위).""" + 산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위) · use = 쓰는 곳(알약 값 · 자리).""" q = _f(qty) t = _f(times) or 1.0 each = None if q is None else q / t @@ -290,7 +302,7 @@ class Builder: return {"kind": "blank", "name": name, "qty": each, "why": "돈 아닌 로직"} return {"kind": "daega", "key": bid, "name": self.blocks[bid]["name"], "unit": doc.get("단위") or "", "qty": each} # fmt: skip - bid = self.precombo(whole, ref, doc, copies, depth + 1) + bid = self.precombo(whole, ref, doc, copies, depth + 1, use) return {"kind": "daega", "key": bid, "name": doc.get("이름") or name, "unit": doc.get("단위") or "", "qty": each} # fmt: skip code = str(one.get("로직") or "") @@ -312,10 +324,27 @@ class Builder: return {"kind": "daega", "key": bid, "name": block["name"], "spec": block["spec"], "spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip - def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str: - """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2).""" + def precombo( + self, + whole: Any, + ref: dict, + doc: dict, + copies: dict, + depth: int = 0, + use: tuple[dict, str] = ({}, ""), + ) -> str: + """사전조합(SC) = 겹 대가 — 로직 줄 = 품셈 호표 · 자재 줄(175-2) · 줄 수량(723-4) = + 옛 손 → use 알약 값 → 없음(`ask`) · use = (쓰는 구조물 사본 면적 알약 값, 덮개 자리).""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store + rid, (given, where) = refs.ref_id(ref), use + loose: list[bool] = [] # 줄마다 손 아닌 수량(알약 · 없음) + + def qty_of(key: str, hand: Any) -> float | None: # 짝 = `M02_Labor_Expand._qty` 차례 + got = _f(hand) + loose.append(got is None) + return _f(given.get(key)) if got is None else got + inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈 pills = { @@ -325,20 +354,20 @@ class Builder: for code in pill["로직"] } lines = [] - for one in inner: - hand = _f((one.get("수량") or {}).get("손")) + for n, one in enumerate(inner): + qty = qty_of(f"{rid}#{n}", (one.get("수량") or {}).get("손")) pill = pills.get(one.get("로직")) or {} times = pill.get("배") if "로직" in one else 1 name = pill.get("이름") or str(one.get("로직") or "") if depth >= refs.DEPTH and isinstance(one.get("참조"), dict): - lines.append({"kind": "blank", "name": name, "qty": hand, "why": "참조 깊이 넘음"}) + lines.append({"kind": "blank", "name": name, "qty": qty, "why": "참조 깊이 넘음"}) continue - qty = 1 if hand is None else hand - lines.append(self.line_of(whole, one, qty, times, name, copies, depth)) - lines += [ - _material(whole, mat, (mat.get("수량") or {}).get("손")) - for mat in doc.get("자재") or [] - ] + lines.append(self.line_of(whole, one, qty, times, name, copies, depth, use)) + for mat in doc.get("자재") or []: + key = f"{rid}#{labor_doc.material_key(mat.get('코드'))}" + lines.append(_material(whole, mat, qty_of(key, (mat.get("수량") or {}).get("손")))) + lines = [ln if ln["kind"] == "blank" or ln.get("qty") is not None else {**ln, "ask": ASK} + for ln in lines] # fmt: skip return self.put( { "kind": "사전조합", @@ -347,9 +376,10 @@ class Builder: "spec_raw": str(ref.get("키") or ""), "unit": doc.get("단위") or "", "cite": "", - "origin": {"참조": refs.ref_id(ref)}, + "origin": {"참조": rid}, "lines": lines, - } + }, + f"C:{rid}|{where}" if where and any(loose) else None, ) # ── 조합(구조물 사본) ─────────────────────────────────────────────── @@ -361,7 +391,9 @@ class Builder: values = read_json(folder / VALUES_FILE) or {} picks = read_json(folder / PICKS_FILE) or {} whole = item_prices.load_prices(prices) - result = item_prices.calc(labor, prices, values, picks, read_json(folder / AREA_FILE)) + area = read_json(folder / AREA_FILE) + result = item_prices.calc(labor, prices, values, picks, area) + use = (labor_doc.area_values(area), cover or f"S:{folder.name}") # SC 안 줄 알약(723-4) doc = item_prices.with_values(labor.get("줄") or {}, values, picks) copies = labor.get("참조사본") or {} lines = [] @@ -371,7 +403,14 @@ class Builder: (p for p in result.get("알약") or [] if p.get("키") == done.get("알약")), {} ) line = self.line_of( - whole, one, done.get("수량"), done.get("배"), pill.get("이름") or name, copies + whole, + one, + done.get("수량"), + done.get("배"), + pill.get("이름") or name, + copies, + 0, + use, ) if line.get("qty") is None and line["kind"] != "blank": line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"} @@ -405,7 +444,7 @@ class Builder: whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2) return self.sangeun(whole, ref, doc, copies, 1) - return self.precombo(whole, ref, doc, copies, 1) + return self.precombo(whole, ref, doc, copies, 1, ({}, f"L:{folder.name}")) def fixed_line(fx: dict, qty: Any) -> dict[str, Any]: diff --git a/resources/tester/test_723_4_b09_sc_qty.py b/resources/tester/test_723_4_b09_sc_qty.py new file mode 100644 index 000000000..1ab872a2c --- /dev/null +++ b/resources/tester/test_723_4_b09_sc_qty.py @@ -0,0 +1,105 @@ +"""723-4 B09 공사 사본의 일위대가(SC) 안 줄 수량 = 프로젝트 값 연결. + +- 723-1 = M02 일위대가(SC) 안 줄 · 자재 수량 저장 안 함(null = 수량 없음 · 1 아님) +- 구조물 조합에서 온 SC = 그 사본 면적 알약 값(「참조 id#차례」 · 「참조 id#자재|코드」)으로 굳힘 +- 내역에서 바로 쓴 SC(서고 사본) = 빈 줄 `ask` → 이 공사에서 입력(702 덮개 qty) → 금액 +- 옛 손(B09 굳힌 사본) = 그대로 · 덮개 열쇠 `C:참조` 그대로(기존 공사 금액 · 덮개 같음) +- 셈 짝(`B09_Estimation_Calc_Labor` .py ↔ .ts) 거울 +""" + +import importlib.util +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import ASK, Builder # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 + +_spec = importlib.util.spec_from_file_location( + "b145", Path(__file__).with_name("test_145_2_b09_labor_calc.py") +) +b145 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(b145) + +REF = {"층": "project", "주인": "t", "키": "SC-000001"} +RID = refs.ref_id(REF) +MAT = "MT00001" +SHEETS = {"labor": [], "material": [{"key": MAT, "price": 500}], "machine": []} + + +def _fix(name, labor): + return {"고정": {"이름": name, "단위": "식", "단가": {"노무비": labor}}} + + +def _doc(hand=None, mat=None): + """SC 한 벌 — 고정 줄 둘(노무 1000 · 2000) + 자재 하나 · 손 = hand · mat(None = 수량 없음).""" + q = (lambda v: {"수량": {"손": v}}) if hand is not None else (lambda v: {"수량": None}) + return { + "이름": "시험 SC", + "단위": "㎡", + "담은로직": [{**_fix("갑", 1000), **q(hand)}, {**_fix("을", 2000), **q(hand)}], + "자재": [{"코드": MAT, "수량": {"손": mat} if mat is not None else None}], + } + + +def _build(doc, use=({}, "")): + build = Builder() + whole = {MAT: {"이름": "철선", "규격": "#8", "단위": "kg"}} # 사본 단가 대신(_material 만 읽음) + bid = build.precombo(whole, REF, doc, {}, 1, use) + block = build.blocks[bid] + return {"blocks": [block], "owners": {"pipe@100.0": bid}}, block + + +def test_수량_없는_SC_서고_사본_경고(): + daega, block = _build(_doc(), ({}, "L:lib")) + assert [ln["qty"] for ln in block["lines"]] == [None, None, None] + assert all(ln["ask"] == ASK for ln in block["lines"]) + assert block["cover"] == f"C:{RID}|L:lib" # 다른 곳 SC 덮개와 안 섞임 + out = compute_labor(daega, SHEETS) + got = out["daega_blocks"][0] + assert got["blank"] == 3 and got["sum"] == 0 + assert [ln["why"] for ln in got["lines"]] == [ASK] * 3 + assert sum(g["why"] == ASK for g in out["gaps"]) == 3 + assert out["daega"][0]["blank"] == 3 # 내역 · 원가계산서 빈 줄(166-6 · 701-3)로 넘어감 + + +def test_이_공사에서_입력_금액(): + daega, block = _build(_doc(), ({}, "L:lib")) + cids = [ln["cid"] for ln in block["lines"]] + cover = {block["cover"]: {cids[0]: {"qty": 2}, cids[1]: {"qty": 0.5}, cids[2]: {"qty": 3}}} + got = compute_labor(daega, SHEETS, None, cover)["daega_blocks"][0] + assert [ln["amount"] for ln in got["lines"]] == [2000.0, 1000.0, 1500.0] + assert got["blank"] == 0 and got["sum"] == 4500.0 + assert not any("why" in ln for ln in got["lines"]) # 입력 뒤 경고 글 없음 + + +def test_구조물_조합_알약_값_굳힘(): + given = {f"{RID}#0": 1.5, f"{RID}#1": 2, f"{RID}#자재|{MAT}": 4} + daega, block = _build(_doc(), (given, "S:pipe@100.0")) + assert [ln["qty"] for ln in block["lines"]] == [1.5, 2.0, 4.0] + assert not any("ask" in ln for ln in block["lines"]) + assert block["cover"] == f"C:{RID}|S:pipe@100.0" + got = compute_labor(daega, SHEETS)["daega_blocks"][0] + assert got["sum"] == 1500.0 + 4000.0 + 2000.0 and got["blank"] == 0 + + +def test_옛_손_그대로(): + daega, block = _build(_doc(hand=3, mat=2), ({}, "S:pipe@100.0")) + assert [ln["qty"] for ln in block["lines"]] == [3.0, 3.0, 2.0] + assert block["cover"] == f"C:{RID}" # 옛 덮개 열쇠 그대로 + got = compute_labor(daega, SHEETS)["daega_blocks"][0] + assert got["sum"] == 3000.0 + 6000.0 + 1000.0 + + +def test_거울_파이썬_TS(): + daega, block = _build(_doc(), ({}, "L:lib")) + cid = block["lines"][0]["cid"] + for cover in ({}, {block["cover"]: {cid: {"qty": 2}}}): + py = json.loads(json.dumps(compute_labor(daega, SHEETS, {}, cover), ensure_ascii=False)) + ts = b145._ts(daega, SHEETS, {}, cover) + assert b145._same(py, ts["out"]) From 6490b999e368ea60d09ccfa1d7701fded0bd1652 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:58:10 +0900 Subject: [PATCH 18/23] =?UTF-8?q?feat(723-2):=20M02=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=83=81=EC=84=B8=20=3D=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=ED=91=9C=EB=A7=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EC=B9=B8=20=ED=9C=98=EB=B0=9C=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD(=EC=9C=84=EB=A1=9C=20=C2=B7=20=EC=95=84=EB=9E=98?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EB=B3=84=EC=B9=AD=20=C2=B7=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C)=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `Labor_Table.ts` = 서고(사전조합) 상세 — 정보 칸 · 일위대가표(laborBlock 격자) · 원문 패널 · 머리 [로직 추가] [자재 추가] · 수량 칸 = 확인용 임시 입력(저장 · dirty 없음 · 짝 셈 settle 로 금액 미리 보기) · 「빈 값 n」 = 비고 붉게 · 붙여넣기 엑셀 수량 = 임시 칸으로 · 옛 문서 손 수량은 읽을 때 걷음(723-1) · lib.fixed(721-2 인력 · 재료 고정 줄) - 새 `Labor_RowModal.ts` = 줄 누름 모달(옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출 보기) · 자재 줄 모달(세 칸) - `Labor_Detail.ts` = mode precombo 면 Labor_Table 로 · 서고 갈래 걷음(679 → 649 줄) - 낡은 시험 고침: test_175 화면 글 대조(서고 갈래가 Labor_Table · RowModal 로 옮김) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor_Detail.ts | 72 +-- .../M02_MasterTemplete_Labor_RowModal.ts | 207 ++++++++ .../M02_MasterTemplete_Labor_Table.ts | 484 ++++++++++++++++++ resources/tester/test_175_precombo_mats.py | 16 +- 4 files changed, 723 insertions(+), 56 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d79b2c472..a3b1aa85f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -10,8 +10,7 @@ * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 - * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · - * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) + * · 서고 모드(50-3 · `mode`) = 일위대가표만 → `Labor_Table`(723-2 · 수량 = 휘발 임시 입력) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`) * ========================================================================== */ @@ -67,7 +66,7 @@ import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; import { addMaterialButton } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; -import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta"; +import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -105,10 +104,10 @@ export function mountLaborDetail( doc: unknown, opt: LaborDetailOptions, ): LaborDetailHandle { + if (opt.mode === "precombo") return mountLaborTable(host, doc, opt); // 723-2 — 서고 = 일위대가표만 const cur = normalizeLabor(doc, opt.column); const saved = JSON.stringify(cur); const edit = !opt.readOnly; - const lib = opt.mode ?? null; const logics = new Map(); let calc: LaborCalc | null = null; /** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */ @@ -168,7 +167,7 @@ export function mountLaborDetail( const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); const drawDg = (): void => { - if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음 + if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 const data = buildLaborDiagram({ rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함 logics: [...logics.values()], @@ -205,8 +204,7 @@ export function mountLaborDetail( move: reorder, remove: removeRow, alias: setAlias, - // 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음) - ...(lib ? {} : { promote: setPromote }), + promote: setPromote, // 134-1 — 구조물 일위대가 로직 줄 ...sk, } : { open: pick, option: openRow, ref: openRef, ...sk }, @@ -214,7 +212,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { - gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed, !lib)); + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed)); drawLogic(); matHost.replaceChildren( matTable( @@ -326,7 +324,6 @@ export function mountLaborDetail( unit, readOnly: !edit, sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, - ...(lib ? { handOnly: true } : {}), done: (r) => { if (r.kind === "keep") return; if (r.kind === "hand") target.수량 = { 손: r.손 }; @@ -515,8 +512,7 @@ export function mountLaborDetail( const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); - const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) - cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 }); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null }); changed(); }, }; @@ -528,9 +524,7 @@ export function mountLaborDetail( (x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); - // 사전조합 = 면적산출 없음 → 손 1(51-11) - const 수량 = lib ? { 손: 1 } : null; - cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 }); + cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); }, // 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422) @@ -587,16 +581,6 @@ export function mountLaborDetail( ], }); - // 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름) - const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); - nameField.root.dataset.laborField = "이름"; - nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); - nameField.input.readOnly = !edit; - nameField.input.addEventListener("input", () => { - (cur as { 이름?: string }).이름 = nameField.input.value.trim(); - opt.onChanged(dirty()); - }); - const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( @@ -613,13 +597,7 @@ export function mountLaborDetail( children: [ el("div", { className: "m01lab__info", - children: [ - ...(lib - ? [nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty()))] - : []), - unit.root, - origin.root, - ], + children: [unit.root, origin.root], }), ], }), @@ -630,7 +608,7 @@ export function mountLaborDetail( section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), ].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만 - ...(opt.card || lib ? [] : [dgBox]), + ...(opt.card ? [] : [dgBox]), ], }), ), @@ -639,30 +617,22 @@ export function mountLaborDetail( // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 void Promise.all([ loadLogics(), - lib // 서고 모드엔 면적산출 없음 - ? undefined - : Promise.all([ - opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null), - import("../A00_Common/spreadsheet/spreadsheet_recalc"), - ]).then( - ([got, { recalcWorkbook }]) => { - area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook }; - links = areaLinks(got.문서); - }, - () => undefined, - ), + Promise.all([ + opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null), + import("../A00_Common/spreadsheet/spreadsheet_recalc"), + ]).then( + ([got, { recalcWorkbook }]) => { + area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook }; + links = areaLinks(got.문서); + }, + () => undefined, + ), ]).then(() => { if (alive) recalc(); }); return { - // 서고 모드 = 문서 종류 그대로 · 열 없음 - getDoc: () => { - if (!lib) return cur; - const out: Record = { ...cur, 종류: "사전조합" }; - delete out.열; - return out as unknown as LaborDoc; - }, + getDoc: () => cur, saveArea: async () => { if (!area || !areaDirty) return false; const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts new file mode 100644 index 000000000..55e61d9eb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_RowModal.ts + * 일위대가표 줄 누름 모달(PLAN 723-2 · 요구서_내역16 2장 4) — 일위대가표에서 빠진 줄 동작을 모달 안으로. + * · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션 · 메모](`Labor_Input` · 켜쌓기율) · + * 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`) · [단가산출 보기](717-3 `Labor_SangeunModal`) + * · 사용자 자재 줄 = 자재 모달(세 칸 · `Labor_MatTable` 한 줄) + * · 고침 = 그 줄 화면 캐시(저장은 [저장]) · 금액은 표가 다시 셈 → `watch` 로 받아 다시 그림 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborCalc, LaborMat, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; +import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; +import { matTable } from "./M02_MasterTemplete_Labor_MatTable"; +import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover"; +import { releaseTied } from "./M02_Labor_LogicMats_Model"; +import { keepKnown, valueLine } from "./M02_Labor_Values_Model"; + +/** 표가 다시 센 답을 받음 — 돌려준 함수 = 그만 받음 */ +export type CalcWatch = (fn: (calc: LaborCalc | null) => void) => () => void; + +export interface RowModalOptions { + row: LaborRow; + logic: ItemLogic | null; + title: string; + /** 일위대가 머리(이름 · 「1 ㎡ 당」) */ + head: string; + /** 줄 분모(SK 단위 기본) */ + unit: string; + scope: SkScope; + readOnly: boolean; + /** 셈 주입(프로젝트 복사본) — [단가산출 보기] 없음 */ + inject: boolean; + /** 사용자 자재 코드 — 로직 자재 [바꾸기]로 같은 품목 막음 */ + userMats: string[]; + watch: CalcWatch; + /** 고침 뒤 — `again` = 서버 다시 셈(입력 · 고름 · 별도계상) · 아니면 화면만 */ + done: (again: boolean) => void; + /** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로 */ + replace?: (row: LaborRow) => void; +} + +export function openRowModal(o: RowModalOptions): void { + const row = o.row; + const edit = !o.readOnly; + const info = el("div", { attrs: { "data-row-head": "" } }); + const mats = el("div", { attrs: { "data-row-mats": "" } }); + let calc: LaborCalc | null = null; + const line = () => calc?.줄.find((l) => !row.참조 && l.로직 === row.로직); + + const draw = (): void => { + const values = valueLine(o.logic?.입력 ?? [], row.입력값); + info.replaceChildren( + createText(o.head, "muted", 14), + ...(values.text ? [createText(values.text)] : []), + ...(values.empty + ? [createText(tc("Labor_Empty").replace("{n}", String(values.empty)), "error")] + : []), + ...(row.켜쌓기율 != null + ? [createText(tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)), "muted")] + : []), + ...(row.메모 ? [createText(row.메모, "muted")] : []), + ); + const lead = row.참조 + ? [] + : logicMatRows([row], calc, !calc, { + open: () => undefined, + ...(edit + ? { + swap: (_r: number, at: number, key: string) => { + if (o.userMats.includes(key)) + return showToast(tc("Labor_AlreadyMat").replace("{value}", key), "error"); + row.고름 = { ...row.고름, [String(at)]: key }; + o.done(true); + }, + reset: (_r: number, at: number) => { + if (!row.고름) return; + delete row.고름[String(at)]; + if (!Object.keys(row.고름).length) delete row.고름; + o.done(true); + }, + mark: (_r: number, at: number, key: MatMarkKey, v: string) => { + setLogicMark(row, at, key, v); + o.done(key === "별도계상"); + draw(); + }, + } + : {}), + }); + mats.replaceChildren( + ...(lead.length + ? [el("h4", { text: tc("Labor_Mats") }), matTable({ mats: [], calc, lead })] + : []), + ); + }; + + const option = createButton({ + label: tc("Labor_Option"), + variant: "ghost", + onClick: () => + openLaborInput({ + title: o.title, + logic: o.logic, + values: row.입력값, + memo: row.메모, + rate: row.켜쌓기율 ?? null, + readOnly: o.readOnly, + done: (next) => { + const values = o.logic ? keepKnown(o.logic.입력, next.입력값).kept : next.입력값; + const same = + JSON.stringify(values) === JSON.stringify(row.입력값) && + next.켜쌓기율 === (row.켜쌓기율 ?? null); + const before = row.입력값; + row.입력값 = values; + releaseTied(row, before, line()?.재료 ?? []); + row.메모 = next.메모; + if (next.켜쌓기율 === null) delete row.켜쌓기율; + else row.켜쌓기율 = next.켜쌓기율; + o.done(!same); + draw(); + }, + }), + }); + option.dataset.rowOption = ""; + const sk = createButton({ + label: tc("Labor_Sangeun"), + variant: "ghost", + onClick: () => + openRowSangeun({ + scope: o.scope, + row, + title: o.title, + head: o.head, + qty: "", + unit: o.unit, + readOnly: o.readOnly, + apply: (p) => { + if (p) row.패치 = p; + else delete row.패치; + o.done(true); + }, + ...(o.replace ? { replace: o.replace } : {}), + }), + }); + sk.dataset.rowSangeun = ""; + const stop = o.watch((c) => { + calc = c; + draw(); + }); + openModal({ + title: o.title, + closeLabel: tc("In_Close"), + width: "960px", + actions: [...(row.참조 ? [] : [option]), ...(o.inject ? [] : [sk])], + beforeClose: () => (stop(), true), + mount: (body) => body.append(info, mats), + }); + draw(); +} + +export interface MatModalOptions { + mat: LaborMat; + watch: CalcWatch; + readOnly: boolean; + /** 세 칸 고침 뒤 — `money` = 별도계상(금액에 닿음) */ + done: (money: boolean) => void; +} + +/** 사용자 자재 줄 — 세 칸(148-4 · 222-1) · 가격 다섯 보기 */ +export function openMatModal(o: MatModalOptions): void { + const host = el("div", { attrs: { "data-row-mat": o.mat.코드 } }); + let calc: LaborCalc | null = null; + const draw = (): void => + host.replaceChildren( + matTable( + { mats: [o.mat], calc }, + o.readOnly + ? { open: () => undefined } + : { + open: () => undefined, + mark: (_i, key, v) => { + setMark(o.mat, key, v); + o.done(key === "별도계상"); + draw(); + }, + }, + ), + ); + const stop = o.watch((c) => { + calc = c; + draw(); + }); + openModal({ + title: `${o.mat.코드} ${o.mat.이름 ?? ""}`.trim(), + closeLabel: tc("In_Close"), + width: "1100px", + beforeClose: () => (stop(), true), + mount: (body) => body.append(host), + }); + draw(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts new file mode 100644 index 000000000..c93137a80 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -0,0 +1,484 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Table.ts + * M02 일위대가(서고 · 사전조합) 상세 = 일위대가표만(PLAN 723-2 · 내역 16 · 17 · 요구서_내역16 2장 3~5). + * · 차례 = 정보(이름 · 구분 · 단위 · 본뜬) → 일위대가표(엑셀 꼴 격자 · `Labor_Grid.laborBlock`) · 오른쪽 원문 패널 + * · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄) + * · 수량 칸 = 확인용 임시 입력(723-1 · 휘발 · 문서 · 저장 · dirty 안 건드림) → 그 줄 금액 · 합계 미리 보기 + * (짝 셈 `Labor_Calc.settle` · 서버 안 부름) · 문서 안 줄 수량은 늘 null(쓰는 쪽 알약으로 이음) + * · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 = 위로 · 아래로 · 별칭 · 삭제 + * · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) · + * 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸) + * · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 수량 걷고 다시 셈) + * 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `Labor_Detail`(면적 잇기 · 수량산출). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createHopyoGrid, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid"; +import { openTreeMenu, type TreeMenuItem } from "@ui/ui_template_tree"; +import { askText, showConfirmDialog } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { getActiveLayer, getActiveOwner, type ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import { lineId, settle, type LaborFixed, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { + calcLabor, + fetchLogicInputs, + normalizeLabor, + type LaborCalc, + type LaborDoc, + type LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail"; +import { laborBlock } from "./M02_MasterTemplete_Labor_Grid"; +import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; +import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; +import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; +import { openMatModal, openRowModal } from "./M02_MasterTemplete_Labor_RowModal"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { insideKeys } from "./M02_Labor_LogicMats_Model"; +import { valueLine } from "./M02_Labor_Values_Model"; +import { addMaterialButton } from "./M02_Material_Pick"; +import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; +import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; +import "./M02_MasterTemplete_Labor.css"; + +const TEXT = { + TempHint: [ + "수량 칸 = 확인용 임시 입력(저장 안 함) — 실제 수량은 구조물 조합 · 프로젝트에서 이음", + "Quantity = temporary check only (not saved) — real quantities link in structures · projects", + ], + Alias: ["별칭", "Alias"], + AliasAsk: ["별칭(비우면 지움)", "Alias (empty = clear)"], + RemoveAsk: ["「{value}」 줄을 지울까?", "Delete line “{value}”?"], + Empty: ["빈 값 {n}", "{n} empty"], + NotNumber: ["수가 아님", "Not a number"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +/** 비율 고정 줄(705-3) — 수량 = 율(문서에 남음 · 임시 입력 아님) */ +const isRate = (row: LaborRow): boolean => !!row.고정?.비율; +const matKey = (code: string): string => `자재|${code}`; + +export function mountLaborTable( + host: HTMLElement, + doc: unknown, + opt: LaborDetailOptions, +): LaborDetailHandle { + const cur = normalizeLabor(doc, opt.column); + // 723-1 — 옛 문서 손 수량은 안 씀(서버도 저장 때 걷음) · 비율 줄 율만 + for (const r of cur.담은로직) if (!isRate(r)) r.수량 = null; + for (const m of cur.자재) m.수량 = null; + const saved = JSON.stringify(cur); + const edit = !opt.readOnly; + const logics = new Map(); + /** 임시 수량(휘발) — 줄 id · 「자재|코드」 → 수 */ + const temp = new Map(); + let calc: LaborCalc | null = null; + let last: LaborCalc | null = null; + let fail: string | null = null; + let seq = 0; + let timer = 0; + let alive = true; + let picked: string | null = null; + const dirty = (): boolean => JSON.stringify(cur) !== saved; + /** 줄 모달이 받는 셈 답(다시 셀 때마다) */ + const watchers = new Set<(c: LaborCalc | null) => void>(); + const watch = (fn: (c: LaborCalc | null) => void): (() => void) => { + watchers.add(fn); + fn(calc ?? last); + return () => watchers.delete(fn); + }; + + /** 셈 몸 = 문서 + 임시 수량(저장 안 함) */ + const withTemp = (): LaborDoc => ({ + ...cur, + 담은로직: cur.담은로직.map((r) => { + const v = temp.get(lineId(r) ?? ""); + return v === undefined ? r : { ...r, 수량: { 손: v } }; + }), + 자재: cur.자재.map((m) => { + const v = temp.get(matKey(m.코드)); + return v === undefined ? m : { ...m, 수량: { 손: v } }; + }), + }); + + const gridHost = el("div", { attrs: { "data-labor-grid": "", "data-labor-table": "" } }); + const source = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); + const side = createSidePanels({ + prefKey: "m02-labor-side", + follow: false, + panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], + onChange: () => showSource(), + }); + function showSource(): void { + if (side.current() !== "source") return; + if (!picked) return void sourceHost.replaceChildren(createText(tc("Labor_SourceHint"))); + source.show(logics.get(picked)?.출처 ?? ""); + if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root); + } + + /** 일위대가표 — 줄 누름 · 우클릭 · 수량 칸(임시) · 빈 값 붉게 */ + const draw = (): void => { + const body = withTemp(); + const block = laborBlock(body, calc ?? last, logics); + const empties = new Set(); + block.lines.forEach((ln, n) => { + const row = body.담은로직[n]; + if (!row) return void (ln.note = ln.note === tc("Grid_NoQty") ? "" : ln.note); // 자재 줄 + const empty = row.로직 ? valueLine(logics.get(row.로직)?.입력 ?? [], row.입력값).empty : 0; + if (empty) empties.add(n); + if (empty) ln.note = t("Empty").replace("{n}", String(empty)); + else if (ln.note === tc("Grid_NoQty")) ln.note = ""; // 수량 없음 = 서고에선 보통(723-1) + }); + const have = calc ?? last; + // 빈 줄 = 단가 모름(까닭) · 빈 값만 — 임시 수량 없는 줄은 안 셈 + block.blank = have ? block.lines.filter((l) => l.prices.sum == null).length + empties.size : 0; + if (fail && block.lines[0]) block.lines[0].note = fail; + const g = hopyoGrid([block]); + const at = (i: number): number | null => g.at.get(i)?.[1] ?? null; + const grid = createHopyoGrid( + [block], + { + onRowClick: (i) => { + const n = at(i); + if (n !== null) openAt(n); + }, + editable: (i, key) => { + const n = at(i); + if (key !== "qty" || n === null) return false; + const row = cur.담은로직[n]; + return !row || !isRate(row); // 임시 입력이라 읽기만 층도 됨 + }, + onEdit: (i, _key, text) => { + const n = at(i); + if (n !== null) setTemp(n, text); + }, + cellClass: (i, key) => { + const n = at(i); + if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank"; + return hopyoCellClass(g.rows, i, key); + }, + }, + edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined, + ); + grid.root.addEventListener("contextmenu", (ev) => { + const tr = (ev.target as Element).closest("tr[data-i]"); + const n = tr ? at(Number(tr.dataset.i)) : null; + if (n === null || !edit) return; + ev.preventDefault(); + openTreeMenu(ev, menuAt(n)); + }); + gridHost.replaceChildren(grid.root); + showSource(); + for (const fn of watchers) fn(calc ?? last); + }; + + /** 서버 다시 셈 — 늦게 온 답은 버림 */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + calc = null; + fail = null; + draw(); + try { + const body = withTemp(); + const got = opt.calc + ? await opt.calc(body) + : await calcLabor(opt.layer, opt.column, body, undefined, opt.owner); + if (!alive || mine !== seq) return; + calc = last = got; + } catch (error) { + if (!alive || mine !== seq) return; + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + draw(); + }, 150); + }; + /** `qtyOnly` = 수량만(임시) — 짝 셈으로 바로 */ + const changed = (qtyOnly = false): void => { + opt.onChanged(dirty()); + if (qtyOnly && calc) { + calc = settle(calc, withTemp(), {}); + draw(); + } else recalc(); + }; + + /** 임시 수량(휘발) — 빈 글 = 지움 · 수 아님 = 알림 */ + function setTemp(n: number, text: string): void { + const row = cur.담은로직[n]; + const key = row ? (lineId(row) ?? "") : matKey(cur.자재[n - cur.담은로직.length]?.코드 ?? ""); + const raw = text.replace(/,/g, "").trim(); + if (!raw) temp.delete(key); + else if (Number.isFinite(Number(raw))) temp.set(key, Number(raw)); + else return showToast(t("NotNumber"), "error"); + calc = calc && settle(calc, withTemp(), {}); + draw(); + } + /** 붙여넣기(705-2) 뒤 — 엑셀 수량은 임시 칸으로(문서엔 안 남김) */ + function afterPaste(): void { + for (const r of cur.담은로직) { + const v = r.수량?.손; + if (isRate(r) || v == null) continue; + temp.set(lineId(r) ?? "", v); + r.수량 = null; + } + void loadLogics().then(() => changed()); + } + + const loadLogics = async (): Promise => { + const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); + if (!want.length) return; + try { + const got = await fetchLogicInputs(want); + for (const l of got.로직) logics.set(l.로직, l); + } catch (error) { + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + }; + + const lineOf = (row: LaborRow) => (calc ?? last)?.줄.find((l) => lineId(l) === lineId(row)); + const nameOf = (row: LaborRow): string => + row.별칭 || + row.고정?.이름 || + (row.참조 ? lineOf(row)?.이름 : logics.get(row.로직)?.이름) || + row.참조?.키 || + row.로직; + + /** 줄 누름 — 줄 갈래마다 */ + function openAt(n: number): void { + const row = cur.담은로직[n]; + if (!row) return openMat(n - cur.담은로직.length); + picked = row.로직 || null; + showSource(); + if (row.참조 && row.참조.키.startsWith("SC-")) + return void openRefEdit({ + ref: row.참조, + title: nameOf(row), + readOnly: !edit || row.참조.층 !== opt.layer, + closed: () => alive && recalc(), + }); + if (row.고정) return; // 고정 줄 = 숫자 그대로(이름 · 별칭은 우클릭) + openRowModal({ + row, + logic: logics.get(row.로직) ?? null, + title: nameOf(row), + head: [String((cur as { 이름?: string }).이름 ?? ""), cur.단위 ? `1 ${cur.단위} 당` : ""] + .filter(Boolean) + .join(" · "), + unit: lineOf(row)?.분모 ?? "", + watch, + scope: { layer: opt.layer, owner: library.owner }, + readOnly: !edit, + inject: !!opt.calc, + userMats: cur.자재.map((m) => m.코드), + done: (again) => changed(!again), + replace: + edit && opt.layer === "personal" && !library.owner + ? (next: LaborRow) => ((cur.담은로직[n] = next), changed()) + : undefined, + }); + } + function openMat(i: number): void { + const mat = cur.자재[i]; + if (!mat) return; + openMatModal({ + mat, + watch, + readOnly: !edit, + done: (money) => changed(!money), + }); + } + + /** 우클릭 — 위로 · 아래로 · 별칭 · 삭제 */ + function menuAt(n: number): TreeMenuItem[] { + const rows = cur.담은로직.length; + const isMat = n >= rows; + const name = isMat ? (cur.자재[n - rows]?.이름 ?? "") : nameOf(cur.담은로직[n]); + const items: TreeMenuItem[] = []; + if (!isMat) { + if (n > 0) items.push({ label: tc("Up"), run: () => move(n, n - 1) }); + if (n < rows - 1) items.push({ label: tc("Down"), run: () => move(n, n + 1) }); + items.push({ label: t("Alias"), run: () => void alias(n) }); + } + items.push({ + label: tc("Remove"), + run: () => + void showConfirmDialog(t("RemoveAsk").replace("{value}", name), tc("Remove")).then( + (ok) => ok && remove(n), + ), + }); + return items; + } + function move(from: number, to: number): void { + cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1)); + changed(); + } + async function alias(n: number): Promise { + const row = cur.담은로직[n]; + const got = await askText({ message: t("AliasAsk"), value: row.별칭 ?? "" }); + if (got === null || got === undefined) return; + row.별칭 = got.trim() || null; + opt.onChanged(dirty()); + draw(); + } + function remove(n: number): void { + const rows = cur.담은로직.length; + if (n < rows) { + temp.delete(lineId(cur.담은로직[n]) ?? ""); + cur.담은로직.splice(n, 1); + } else { + temp.delete(matKey(cur.자재[n - rows]?.코드 ?? "")); + cur.자재.splice(n - rows, 1); + } + changed(); + } + + /** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */ + const library = { + layer: opt.layer, + owner: + opt.owner !== undefined + ? opt.owner + : opt.layer === getActiveLayer() + ? getActiveOwner() + : null, + pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { + const id = lineId({ 참조: ref }); + if (cur.담은로직.some((r) => lineId(r) === id)) + return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null }); + changed(); + }, + fixed: (fx: LaborFixed, name: string) => { + const id = lineId({ 고정: fx }); + if (cur.담은로직.some((r) => lineId(r) === id)) + return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); + cur.담은로직.push({ 로직: "", 고정: fx, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + changed(); + }, + }; + const addLogic = createButton({ + label: tc("AddLogic"), + variant: "filled", + onClick: () => + openLogicPicker((x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(() => changed()); + }, withHopyo(library)), + }); + const addMat = addMaterialButton( + tc("Labor_AddMat"), + (m) => { + const why = insideKeys((calc ?? last)?.줄).has(m.코드) + ? "Labor_InLogicMat" + : cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat"; + if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error"); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }, + opt.layer === "personal" ? { owner: library.owner } : undefined, + ); + + // 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬 + const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); + nameField.root.dataset.laborField = "이름"; + nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); + nameField.input.readOnly = !edit; + nameField.input.addEventListener("input", () => { + (cur as { 이름?: string }).이름 = nameField.input.value.trim(); + opt.onChanged(dirty()); + }); + const unit = el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }); + const unit0 = cur.단위; + const drawUnit = (): void => + unit.replaceChildren( + el("label", { className: "ui-field__label", text: tc("Labor_Unit") }), + createUnitPick({ + value: cur.단위 || NO_UNIT, + extra: [unit0, cur.단위].filter((u): u is string => !!u), + readOnly: !edit, + ariaLabel: tc("Labor_Unit"), + onPick: (u) => { + cur.단위 = u === NO_UNIT ? null : u; + opt.onChanged(dirty()); + drawUnit(); + draw(); + }, + }), + ); + drawUnit(); + const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); + origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.readOnly = true; + + host.replaceChildren( + el("div", { + className: "ui-side-panels-row", + children: [ + el("div", { + className: "m01-logic__editor m01-combo m02-labor__edit", + attrs: { "data-labor": opt.column }, + children: [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01lab__info", + children: [ + nameField.root, + ...libMeta(cur, edit, () => opt.onChanged(dirty())), + unit, + origin.root, + ], + }), + ], + }), + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h3", { text: tc("Grid_Title") }), + ...(edit ? [addLogic, addMat] : []), + ], + }), + createText(t("TempHint"), "hint"), + gridHost, + ], + }), + ], + }), + side.root, + ], + }), + ); + draw(); + void loadLogics().then(() => alive && recalc()); + + return { + getDoc: () => { + const out: Record = { ...cur, 종류: "사전조합" }; + delete out.열; + return out as unknown as LaborDoc; + }, + saveArea: async () => false, + destroy: () => { + alive = false; + window.clearTimeout(timer); + host.replaceChildren(); + }, + }; +} diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 2713c1f34..bfa0df359 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -35,11 +35,17 @@ def _save(folder, doc): def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811 - detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() - assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail - assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail - assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail - assert "delete out.자재" not in detail and "delete out.열" in detail + """723-2 — 서고 = 일위대가표만(`Labor_Table`) · [자재 추가] 머리 · 세 칸 = 줄 모달(`Labor_RowModal`).""" + read = lambda name: open(os.path.join(SRC, name), encoding="utf-8").read() # noqa: E731 + detail, lib = ( + read("M02_MasterTemplete_Labor_Detail.ts"), + read("M02_MasterTemplete_Labor_Table.ts"), + ) + modal = read("M02_MasterTemplete_Labor_RowModal.ts") + assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail + assert "...(edit ? [addLogic, addMat] : [])" in lib + assert "setLogicMark(row, at, key, v);" in modal and "setMark(o.mat, key, v);" in modal + assert "delete out.자재" not in lib and "delete out.열" in lib def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811 From 23d8e2d7a2e310d2feab403a48051dce93c3df5a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:59:18 +0900 Subject: [PATCH 19/23] =?UTF-8?q?fix(720-4):=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=20=EC=B9=B8=20=3D=20=EB=88=84=EA=B5=AC?= =?UTF-8?q?=EB=82=98=20=EB=8A=98=20=EB=B3=B4=EC=9E=84(=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0)=20=C2=B7=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EB=B3=B8=EB=96=A0=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=EB=8B=A8=EC=B6=94=EB=A7=8C=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts | 10 ++++------ resources/tester/test_720_m02_folder_layer.py | 8 ++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 53f3955cc..fd83c0998 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -161,10 +161,8 @@ export function buildSide(opt: SideOptions): SideHandle { ? void Promise.all([precombo.refresh(), sangeun.refresh()]) : document.removeEventListener(HOPYO_BUILT, onBuilt); document.addEventListener(HOPYO_BUILT, onBuilt); - // 도면 양식은 시스템 층(마스터 템플릿)에만 — 시스템 관리자면 늘(720-4) - const drawing = opt.isSystem - ? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")) - : null; + // 도면 양식 칸 = 누구나 늘(읽기 · 고르기) · 새로 · 본떠 · 지우기 단추만 시스템 관리자 + const drawing = section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")); // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 시스템 관리자만 const practice = opt.isSystem ? buildPracticeSection({ @@ -188,7 +186,7 @@ export function buildSide(opt: SideOptions): SideHandle { summary, createDivider(), section(hopyo.title, hopyo.body), - ...(drawing ? [drawing] : []), + drawing, ...(practice ? [section(practice.title, practice.body)] : []), ], }); @@ -259,7 +257,7 @@ export function buildSide(opt: SideOptions): SideHandle { const mine = ++seq; paintButtons(); try { - items = opt.isSystem ? await listTemplates(layer, null) : []; // 도면 양식 목록 — 시스템 관리자만 + items = await listTemplates(layer, null); // 도면 양식 목록 — 누구나 } catch (error) { items = []; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); diff --git a/resources/tester/test_720_m02_folder_layer.py b/resources/tester/test_720_m02_folder_layer.py index 511e97ab0..a22252dab 100644 --- a/resources/tester/test_720_m02_folder_layer.py +++ b/resources/tester/test_720_m02_folder_layer.py @@ -78,7 +78,7 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기(): def test_구조물_조합_층_제자리_바꿈(): """720-4 — 해시 이동 없음 · replaceState · 나무 · 층 줄 · 띠를 감싼 칸에 갈아 끼움 · 권한 = 함수 · - 도면 양식 · 실무문서 = 시스템 관리자면 늘 · 불러오기 장부 = 열 때 층 · 권한.""" + 도면 양식 = 누구나(단추는 관리자) · 실무문서 = 시스템 관리자 · 불러오기 장부 = 열 때 층 · 권한.""" page = _text("M02_MasterTemplete_UI_Page.ts") assert "navigateTo" not in page switch = page.split("const switchStructure = async")[1].split("const go =")[0] @@ -91,6 +91,10 @@ def test_구조물_조합_층_제자리_바꿈(): assert "() => isAdmin," in page and 'isSystem: user?.role === "SYSTEM_ADMIN",' in page assert "main.root.isConnected ? void afterItem()" in page # 나무를 갈아도 듣기 그대로 side = _text("M02_MasterTemplete_UI_Side.ts") - assert "const drawing = opt.isSystem" in side and "const practice = opt.isSystem" in side + assert "const drawing = section(" in side and "const practice = opt.isSystem" in side + # 도면 양식 칸 = 누구나 · 단추만 관리자(720-4 덧) + assert "const drawing = opt.isSystem" not in side + assert 'if (!opt.isSystem) return [];' in side + assert "items = await listTemplates(layer, null);" in side hopyo = _text("M02_MasterTemplete_Hopyo_Side.ts") assert "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo From 9633e97c3c2d532e9c785a207ff021a88fe2a09b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 22:25:30 +0900 Subject: [PATCH 20/23] =?UTF-8?q?feat(723-4):=20723-1=20=EB=92=A4=20B09=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=A7=88=EB=AC=B4=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EA=B0=92=20=EC=93=B4=20SC=20=EB=A7=8C=20?= =?UTF-8?q?=EB=8D=AE=EA=B0=9C=20=EB=82=98=EB=88=94=20=C2=B7=20SK=20?= =?UTF-8?q?=EC=86=8D=20SC=20=C2=B7=20=EA=B3=B5=EC=82=AC=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=88=98=EB=9F=89=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=EA=B3=A0=20=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor_Source = 덮개 열쇠 `C:참조|S:사본` 은 알약 값 쓴 SC 블록만(빈 수량 · 옛 손 = `C:참조` 그대로 = 703-1 지난 공사 가져옴 꼴) · SK 속 SC 안 줄 수량 없음 = 엔진엔 0 값묶음(`_Loose`) · 그 줄 = 사전조합 블록(이 공사 입력) — SK 통째 빈칸 안 됨 - Bill_Hopyo(707-7 공사 통째 불러오기) = 이 공사 엑셀 수량을 굳힌 손으로 남김(723-1 `with_pills` 수량 걷기는 M02 저장용) - Daega_Personal 원천 = 블록 origin 참조(덮개 열쇠 꼬리 무관) · 내 자료집 저장 알림 글 = 줄 수량 저장 안 함 - 비고(펼침 · 일위대가표) = 수량 없는 SC 안 줄은 「산근 n호표 · 수량 없음 — 이 공사에서 입력」(`noteOf`) - 낡은 시험 고침(사유 = 723-1 SC 안 줄 수량 저장 안 함 · 이 공사 입력 · 알약으로 이음): test_204_2(서고 SC 수량 없음 · ask) · test_703(자료집 저장 손 걷힘) · test_719_1(서고 사본 = 알약 값으로 같은 값 · 없으면 수량 없음) · test_719_2(SK 속 SC = 빈 줄 → 이 공사 입력 1 = M02 알약 1 금액) · test_702_3(서고 SC 빈 줄 3 → 입력 뒤 금액) - test_723_4 = 구조물 사본 면적 알약 굳힘 · 설계 연동 · 알약 안 이음 = 빈 줄 시험 더함 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Hopyo.py | 4 +- .../B09_Estimation_Daega_Personal.py | 6 +- B09_Estimation/B09_Estimation_Labor_Source.py | 33 ++++++++--- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 ++ .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 4 +- .../B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts | 2 +- .../B09_Estimation_UI_Tabs_Labor.ts | 3 +- .../tester/test_204_2_b09_bill_library.py | 11 +++- .../tester/test_702_3_b09_estimation_live.py | 4 ++ resources/tester/test_703_b09_daega_copy.py | 8 ++- resources/tester/test_719_1_sk_freeze.py | 15 ++++- resources/tester/test_719_2_sk_project.py | 39 +++++++++++-- resources/tester/test_723_4_b09_sc_qty.py | 55 ++++++++++++++++++- 13 files changed, 159 insertions(+), 31 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Hopyo.py b/B09_Estimation/B09_Estimation_Bill_Hopyo.py index 66fda419b..e3f32b602 100644 --- a/B09_Estimation/B09_Estimation_Bill_Hopyo.py +++ b/B09_Estimation/B09_Estimation_Bill_Hopyo.py @@ -141,7 +141,9 @@ def _copies(docs: dict[int, dict[str, Any]], tag: str, no: int) -> dict[str, dic rid = refs.ref_id(_ref(tag, at)) if rid in out or at not in docs: continue - out[rid] = refs.with_pills(docs[at]) + # 이 공사 엑셀 수량 = 굳힌 손(723-4 · `with_pills` 의 723-1 수량 걷기는 M02 저장용) + doc = docs[at] + out[rid] = {**refs.with_pills(doc), **{k: doc.get(k) or [] for k in ("담은로직", "자재")}} todo += [ int(one["참조"]["키"][3:]) for one in docs[at].get("담은로직") or [] diff --git a/B09_Estimation/B09_Estimation_Daega_Personal.py b/B09_Estimation/B09_Estimation_Daega_Personal.py index d35d2a962..36005a843 100644 --- a/B09_Estimation/B09_Estimation_Daega_Personal.py +++ b/B09_Estimation/B09_Estimation_Daega_Personal.py @@ -1,7 +1,8 @@ """B09 「내 자료집에 저장」(PLAN 703-2) — 펼친 일위대가(대가 블록) → 개인 층 사전조합(SC). 담는 것 = 담은로직(넣을 값 · 고른 자재 얹음) · 자재 · 줄 수량(= 수량산출 수기 `손`) — - 맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림) + 맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림) · + SC 쓸 때 손 걷힘(723-1 `refs.no_qty` · 쓰는 곳 알약 · 이 공사 입력으로 이음) 안 담는 것 = 품 덮개(부른 블록 안 줄 · 품셈 블록 줄 — 로직 통째 배수로 바꾸면 다른 줄까지 바뀜) · 단가 덮개(사전조합 꼴에 단가 칸 없음) → 수만 알림(품은 PLAN 705 고정 줄이 생기면 담김) 원천 = 블록 cover — 조합 `S:`/`L:` = 사본 폴더(일위대가 · 넣을 값 · 고른 자재 · 셈 수량) · @@ -107,7 +108,8 @@ def source(root: Path, block: dict[str, Any], base: str) -> tuple[list[dict], li doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(refs.ref_id(ref)) if isinstance(doc, dict): return _precombo(doc) - if head == "C" and (doc := _frozen(root, rest)) is not None: + rid = (block.get("origin") or {}).get("참조") or rest # 덮개 `C:참조|쓰는 곳`(723-4) = 참조만 + if head == "C" and (doc := _frozen(root, rid)) is not None: return _precombo(doc) raise LookupError(f"일위대가 원천을 못 찾음 「{base}」") diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 5dd10e815..5d0f43c11 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -7,8 +7,8 @@ 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄 · 자재 줄(175-2) · 안 줄 수량(723-4) = 굳힌 옛 손 → 쓰는 구조물 사본 면적 알약 값(723-1 「참조 id#차례」 · 「참조 id#자재|코드」) → 없음 = 빈 줄(`ask` · 이 공사에서 입력 = - 702 덮개 qty) · - 알약 · 없음 쓴 블록 덮개 = `C:참조|S:사본` · `C:참조|L:서고`(옛 손뿐 = `C:참조`) + 702 덮개 qty) · 알약 값 쓴 블록 덮개 = `C:참조|S:사본`(사본마다 값 다름) · + 그 밖 = `C:참조`(공사 안 한 덮개) 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) @@ -173,11 +173,11 @@ class Builder: 단가산출 속 일위대가 · SK 줄(참조) = 그 대가 블록을 부르는 줄(펼침 · B09 차례 그대로) · 고정 줄 = `fixed` · 단가 고친 줄(덮개 · 더한줄 단가 · 값묶음) = `fixed`(줄 단가 = M02 셈) · cover = `P:` + SK 이름표(SK 를 고쳐 [업데이트] 해도 B09 덮개 그대로) · + 속 일위대가 안 줄 수량 없음(723-4) = 엔진엔 0 값묶음 · 그 줄 = 사전조합 블록(이 공사 입력) · 줄 `sk` · `poom`(M02 에서 고친 줄의 품셈 값 = 원본 덮개 `원값`).""" from M02_MasterTemplete import M02_Item_Sangeun as item_sk from M02_MasterTemplete import M02_Sangeun_Calc as calc from M02_MasterTemplete import M02_Sangeun_Doc as sd - from M02_MasterTemplete.M02_Labor_Expand import Expand rid = refs.ref_id(ref) key = str((doc.get("바탕") or {}).get("로직") or "") @@ -190,7 +190,7 @@ class Builder: try: got = calc.resolve(doc, whole, read, (rid,)) row = {**got["줄"], "키": key, "호표": [dict(x) for x in got["줄"]["호표"]]} - if calc._bundles(row, whole, Expand(whole, read), (rid,)): + if calc._bundles(row, whole, _Loose(whole, read), (rid,)): return None except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None @@ -334,15 +334,15 @@ class Builder: use: tuple[dict, str] = ({}, ""), ) -> str: """사전조합(SC) = 겹 대가 — 로직 줄 = 품셈 호표 · 자재 줄(175-2) · 줄 수량(723-4) = - 옛 손 → use 알약 값 → 없음(`ask`) · use = (쓰는 구조물 사본 면적 알약 값, 덮개 자리).""" + 굳힌 손 → use 알약 값 → 없음(`ask`) · use = (쓰는 구조물 사본 면적 알약 값, 덮개 자리).""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store rid, (given, where) = refs.ref_id(ref), use - loose: list[bool] = [] # 줄마다 손 아닌 수량(알약 · 없음) + pilled: list[bool] = [] # 줄마다 알약 값을 썼나 def qty_of(key: str, hand: Any) -> float | None: # 짝 = `M02_Labor_Expand._qty` 차례 got = _f(hand) - loose.append(got is None) + pilled.append(got is None and key in given) return _f(given.get(key)) if got is None else got inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] @@ -379,7 +379,7 @@ class Builder: "origin": {"참조": rid}, "lines": lines, }, - f"C:{rid}|{where}" if where and any(loose) else None, + f"C:{rid}|{where}" if where and any(pilled) else None, ) # ── 조합(구조물 사본) ─────────────────────────────────────────────── @@ -444,7 +444,22 @@ class Builder: whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2) return self.sangeun(whole, ref, doc, copies, 1) - return self.precombo(whole, ref, doc, copies, 1, ({}, f"L:{folder.name}")) + return self.precombo(whole, ref, doc, copies, 1) + + +def _Loose(whole: Any, read: Any) -> Any: + """B09 SK 속 펼침(723-4) — 사전조합 안 줄 수량 없음 = 0 값묶음(엔진 차례 · 수량만 씀 · + 그 줄 값 = B09 사전조합 블록 · 이 공사에서 입력) · 그 밖 까닭은 그대로.""" + from M02_MasterTemplete.M02_Labor_Expand import NO_QTY, Expand + + class Loose(Expand): + def __call__(self, ref: dict, trail: tuple[str, ...] = ()) -> dict[str, Any]: + got = super().__call__(ref, trail) + if got.get("기준") is None and str(got.get("까닭") or "").endswith(NO_QTY): + return {**got, "기준": dict.fromkeys(labor_doc.COSTS, Decimal(0))} + return got + + return Loose(whole, read) def fixed_line(fx: dict, qty: Any) -> dict[str, Any]: diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1a0c479a3..a91838c22 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -158,6 +158,12 @@ export const blockLevel = (key: string, top: (index: number) => boolean): TabTab of: (i) => (top(i) ? 1 : 2), }); +/** 대가 줄 비고 — 호표 번호 · 까닭 · 수량 없는 SC 안 줄(723-4 `ask`)은 번호 뒤에도 경고 */ +export const noteOf = (ln: Row): string => + ln.ask && ln.qty == null + ? [ln.ref_no, ln.why].filter(Boolean).join(" · ") + : ln.ref_no || ln.why || ""; + /* ── 열 꼴 ────────────────────────────────────────────────────────────── */ export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index f253eab01..50ff41147 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -22,7 +22,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { Row } from "./B09_Estimation_Calc_Base"; -import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs"; +import { money, noteOf, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu"; import { API_BASE_URL } from "@config/config_frontend"; @@ -139,7 +139,7 @@ function foldCard( expense_price: line.prices?.expense, amount: line.amount, base: baseText(line), - note: line.ref_no || line.why || "", + note: noteOf(line), })); const edited = lines.filter((at) => (at.line.covered || []).length); const columns = [ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts index 34c593700..af5d1b2a0 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -95,7 +95,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise { title: "내 자료집에 저장", message: sk ? "내 자료집 단가산출로 — 옵션 값 · 고른 자재 · 품 · 단가 고친 칸까지" - : "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지", + : "내 자료집 일위대가로 — 옵션 값 · 고른 자재까지(줄 수량은 저장 안 함 · 쓰는 곳에서 이음)", value: block.name || "", okLabel: "저장", }); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 11bbd0ade..e723c24a9 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -15,6 +15,7 @@ import type { Row } from "./B09_Estimation_Calc_Base"; import { blockLevel, money, + noteOf, text, type Link, type TabBuild, @@ -99,7 +100,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { unit: ln.unit || "", prices: { sum: ln.price, ...ln.prices }, amounts: { sum: ln.amount, ...ln.amounts }, - note: ln.ref_no || ln.why || "", + note: noteOf(ln), tag: ln.kind === "fixed" ? HOPYO_TAG.fix : HOPYO_TAG.pum, // 705-1 고정 줄 })), })), diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index 1a6a9a5c6..d7854a95d 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -27,7 +27,7 @@ if str(ROOT) not in sys.path: from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 from B09_Estimation import B09_Estimation_Router as router # noqa: E402 from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 -from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import ASK, daega_inputs # noqa: E402 from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 from config import config_system # noqa: E402 from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 @@ -62,7 +62,9 @@ def world(tmp_path, monkeypatch): company = layers.company_dir(7) company.mkdir(parents=True) with store.at(company): - other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"] + other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})[ + "이름" + ] project = tmp_path / "storage" / "7" / "1" / "proj" project.mkdir(parents=True) return {"system": system, "key": key, "company": other, "root": project} @@ -84,7 +86,10 @@ def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사 got, block = _block(root, folder) assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡") kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]] - assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)] + # 723-1 · 723-4 = M02 SC 안 줄 수량 저장 안 함 → 내역에서 바로 쓴 SC = 수량 없음(이 공사에서 입력) + assert kinds == [("sangeun", None), ("sangeun", None), ("material", None)] + assert all(ln["ask"] == ASK for ln in block["lines"]) + assert block["cover"] == f"C:{block['origin']['참조']}" # 공사 안 한 덮개 그대로 assert got["owners"] == {} # 구조물 사본 없어도 호표 # 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음) bundle = json.loads((copy / "단가.json").read_text("utf-8")) diff --git a/resources/tester/test_702_3_b09_estimation_live.py b/resources/tester/test_702_3_b09_estimation_live.py index 0e54c70ec..99d29ad70 100644 --- a/resources/tester/test_702_3_b09_estimation_live.py +++ b/resources/tester/test_702_3_b09_estimation_live.py @@ -44,6 +44,10 @@ def test_서고_사본_산근_목록에_듦_대가_단가_채움(world): by_key = {r["key"]: r for r in sg["sg_list"]} labor = compute_labor(daega, {**base, "gaps": []}, by_key) row = next(r for r in labor["daega"] if r["key"] == block["id"]) + assert not row["sum"] and row["blank"] == 3 # 723-4 서고 SC 안 줄 수량 없음 = 이 공사에서 입력 + cover = {block["cover"]: {ln["cid"]: {"qty": q} for ln, q in zip(block["lines"], (2, 1, 3))}} + labor = compute_labor(daega, {**base, "gaps": []}, by_key, cover) + row = next(r for r in labor["daega"] if r["key"] == block["id"]) assert row["sum"] and not row.get("blank") diff --git a/resources/tester/test_703_b09_daega_copy.py b/resources/tester/test_703_b09_daega_copy.py index c4930ef5c..94d823351 100644 --- a/resources/tester/test_703_b09_daega_copy.py +++ b/resources/tester/test_703_b09_daega_copy.py @@ -3,7 +3,8 @@ - 복사본 = 덮개 `K:…` 의 `@copy` {from · name · spec} — 셈 짝(`Calc_Labor` 파이썬 · TS 거울)이 원본 블록을 본떠 끝 번호 새 호표 · 덮개 = 제 것 · 원본 없음 = 짝 잃음 · 저장 다듬기 · 서고 사본 지킴(prune) -- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 손 · 품 · 단가 덮개 = 안 담음 수 · +- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 문서 손(쓸 때 걷힘 · 723-1) · + 품 · 단가 덮개 = 안 담음 수 · 같은 이름 409 → 덮어쓰기 · 새 이름 """ @@ -178,7 +179,8 @@ def test_자료집_같은_이름_물음(world): saved = json.loads( (world["folder"] / "precombo" / f"{first['이름']}.json").read_text(encoding="utf-8") ) - assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] == {"손": 9} + # 723-1 = M02 SC 안 줄 수량 저장 안 함(덮어쓴 손 9 도 걷힘 · 쓰는 곳 알약 · 이 공사 입력으로 이음) + assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] is None assert saved["담은로직"][0]["입력값"] == b145.STONE assert len(list((world["folder"] / "precombo").glob("SC-*.json"))) == 2 @@ -212,5 +214,5 @@ def test_자료집_길_같은_이름_409_덮어쓰기(world, monkeypatch): over = client.post(url, json={**body, "overwrite": first.json()["키"]}) assert over.status_code == 200 and over.json()["키"] == first.json()["키"] saved = world["folder"] / "precombo" / f"{first.json()['키']}.json" - assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] == {"손": 4} + assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] is None # 723-1 assert client.post(url, json={**body, "cover": "S:없음"}).status_code == 404 diff --git a/resources/tester/test_719_1_sk_freeze.py b/resources/tester/test_719_1_sk_freeze.py index bcaf387f8..d0d1217b8 100644 --- a/resources/tester/test_719_1_sk_freeze.py +++ b/resources/tester/test_719_1_sk_freeze.py @@ -93,15 +93,24 @@ def test_B09_서고_사본_값_같음_원본판(world, tmp_path): "precombo", {"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]}, )["이름"] - _, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1) + # 723-1 = SC 안 줄 수량(손 2) 저장 안 함 → 쓰는 쪽 알약 값으로 이음(층 · 사본 같은 값) + pill = f"{refs.ref_id({**MINE, '키': sc})}#0" + with store.at(world["personal"]): + top = labor.clean( + {"열": "rv", "담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]} + ) + live = labor.calc(top, {pill: 2})["줄"][0] + area = {"시트": [{"id": "s", "칸": {"A1": {"값": 2}}, "연결": [{"칸": "A1", "키": pill}]}]} before = _print(world["personal"]) root = tmp_path / "proj" made = library.import_copy(root, "precombo", "personal", "7/42", sc) assert made["만듦"] and _print(world["personal"]) == before folder = library.library_dir(root) / made["폴더"] read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")} - got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0] - assert not got["까닭"] + bare = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0] + assert bare["기준"] is None and "수량 없음" in bare["까닭"] # 이은 값 없음 = 1 로 안 셈 + got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, area)["줄"][0] + assert not got["까닭"] and not live["까닭"] assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9) mark = read["manifest.json"]["원본판"] built = library._build("precombo", "personal", "7/42", sc)[0] diff --git a/resources/tester/test_719_2_sk_project.py b/resources/tester/test_719_2_sk_project.py index 252707852..f121cb5cf 100644 --- a/resources/tester/test_719_2_sk_project.py +++ b/resources/tester/test_719_2_sk_project.py @@ -21,7 +21,7 @@ from B09_Estimation import B09_Estimation_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation.B09_Estimation_Calc_Base import compute_base from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor -from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs +from B09_Estimation.B09_Estimation_Labor_Source import ASK, daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from M02_MasterTemplete import M02_Item_Copy as item_copy from M02_MasterTemplete import M02_Item_Snapshot as snapshots @@ -122,15 +122,46 @@ def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path): if not _engine_ready(world): pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함") key = _mine(world, full=True) - live = _calc(world["personal"], key) + # 723-1 = 속 일위대가 안 줄 수량 저장 안 함 → M02 SK = 수량 없음 · B09 = 그 줄만 빈 줄(이 공사 입력) + assert "수량 없음" in _calc(world["personal"], key)["까닭"] root, folder = _lib(world, tmp_path, key) daega, done, _ = _b09(root) block = done[daega["libs"][folder]] lines = block["lines"] assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000) sc_line = next(ln for ln in lines if ln["kind"] == "daega") - assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2 - _same_money(block, live) + sc = done[sc_line["key"]] + assert sc["kind"] == "사전조합" and sc_line["qty"] == 2 + assert sc["blank"] == 1 and sc["lines"][0]["why"] == ASK and block["blank"] >= 1 + # 이 공사에서 입력(702 덮개 qty 1) = 옛 손 1 과 같은 값 → M02 셈(알약 값 1) 과 같은 금액 + cover = {sc["cover"]: {sc["lines"][0]["cid"]: {"qty": 1}}} + block = _b09(root, cover)[1][daega["libs"][folder]] + assert block["blank"] == 0 + sc_ref = next(x for x in _added(world, key) if "참조" in x)["참조"] + _same_money(block, _calc_given(world["personal"], key, {f"{refs.ref_id(sc_ref)}#0": 1})) + + +def _added(world, key): + with store.at(world["personal"]): + return store.read("sangeun", key)["문서"]["더한줄"] + + +def _calc_given(folder, key, pills): + """M02 SK 셈 — 속 일위대가 안 줄 수량 = 알약 값(723-1 `Expand` given).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Sangeun_Calc as calc + from M02_MasterTemplete import M02_Sangeun_Doc as sd + from M02_MasterTemplete.M02_Labor_Expand import Expand + + whole = m01.loaded()[1] + with store.at(folder): + clean = sd.clean(store.read("sangeun", key)["문서"], None, whole) + me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) + got = calc.run( + clean, whole, None, refs.read_ref, Expand(whole, refs.read_ref, pills), (me,) + ) + assert not got.get("까닭") + return got def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로( diff --git a/resources/tester/test_723_4_b09_sc_qty.py b/resources/tester/test_723_4_b09_sc_qty.py index 1ab872a2c..f335b4e46 100644 --- a/resources/tester/test_723_4_b09_sc_qty.py +++ b/resources/tester/test_723_4_b09_sc_qty.py @@ -3,7 +3,8 @@ - 723-1 = M02 일위대가(SC) 안 줄 · 자재 수량 저장 안 함(null = 수량 없음 · 1 아님) - 구조물 조합에서 온 SC = 그 사본 면적 알약 값(「참조 id#차례」 · 「참조 id#자재|코드」)으로 굳힘 - 내역에서 바로 쓴 SC(서고 사본) = 빈 줄 `ask` → 이 공사에서 입력(702 덮개 qty) → 금액 -- 옛 손(B09 굳힌 사본) = 그대로 · 덮개 열쇠 `C:참조` 그대로(기존 공사 금액 · 덮개 같음) +- 옛 손(B09 굳힌 사본) = 그대로 · 덮개 열쇠 `C:참조` 그대로(기존 공사 금액 · 덮개 같음) · + 알약 값 쓴 블록만 `C:참조|S:사본`(사본마다 값 다름 · 이 공사 입력 덮개와 안 섞임) - 셈 짝(`B09_Estimation_Calc_Labor` .py ↔ .ts) 거울 """ @@ -59,7 +60,7 @@ def test_수량_없는_SC_서고_사본_경고(): daega, block = _build(_doc(), ({}, "L:lib")) assert [ln["qty"] for ln in block["lines"]] == [None, None, None] assert all(ln["ask"] == ASK for ln in block["lines"]) - assert block["cover"] == f"C:{RID}|L:lib" # 다른 곳 SC 덮개와 안 섞임 + assert block["cover"] == f"C:{RID}" # 공사 안 한 덮개(703-1 지난 공사 가져옴 꼴 그대로) out = compute_labor(daega, SHEETS) got = out["daega_blocks"][0] assert got["blank"] == 3 and got["sum"] == 0 @@ -103,3 +104,53 @@ def test_거울_파이썬_TS(): py = json.loads(json.dumps(compute_labor(daega, SHEETS, {}, cover), ensure_ascii=False)) ts = b145._ts(daega, SHEETS, {}, cover) assert b145._same(py, ts["out"]) + + +def _structure(tmp_path, area): + """구조물 사본(참조 SC 한 줄 · 굳힌 SC = 수량 없음) + 면적.json(SC 안 줄 알약 연결 · 723-1).""" + from M02_MasterTemplete import M02_Item_Snapshot as snapshots + + root = b145._project(tmp_path / "proj", [(100, "SS000901")]) + sc = {**_doc(), "자재": []} + b145._snap(root, "pipe@100.0", "SS000901", [{"참조": REF, "수량": {"손": 1}}], {}, {RID: sc}) + folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0") + if area is not None: + (folder / "면적.json").write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") + return root, folder + + +def _sc_block(root): + from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs + + got = daega_inputs(root) + combo = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@100.0"]) + return got, next(b for b in got["blocks"] if b["id"] == combo["lines"][0]["key"]) + + +def test_구조물_사본_면적_알약_값_굳힘_설계_연동(tmp_path): + links = [{"칸": "A1", "키": f"{RID}#0"}, {"칸": "A2", "키": f"{RID}#1"}] + area = {"시트": [{"id": "s", "칸": {"A1": {"값": 1.5}, "A2": {"값": 2}}, "연결": links}]} + root, folder = _structure(tmp_path, area) + got, block = _sc_block(root) + assert [ln["qty"] for ln in block["lines"]] == [1.5, 2.0] + assert block["cover"] == f"C:{RID}|S:{folder.name}" and not any( + "ask" in ln for ln in block["lines"] + ) + out = compute_labor(got, SHEETS) + by_id = {b["id"]: b for b in out["daega_blocks"]} + assert by_id[block["id"]]["sum"] == 1500.0 + 4000.0 + assert by_id[got["owners"]["pipe@100.0"]]["sum"] == 5500.0 # 조합 = SC 1 단위 × 1 + # 설계 연동 — 면적 값이 바뀌면 다시 셈(사본 면적.json) + area["시트"][0]["칸"]["A1"]["값"] = 3 + (folder / "면적.json").write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") + assert [ln["qty"] for ln in _sc_block(root)[1]["lines"]] == [3.0, 2.0] + + +def test_구조물_사본_알약_안_이으면_빈_줄(tmp_path): + root, folder = _structure(tmp_path, None) + got, block = _sc_block(root) + assert [ln["qty"] for ln in block["lines"]] == [None, None] + assert block["cover"] == f"C:{RID}" # 알약 안 씀 = 서고 SC 와 같은 「이 공사 입력」 덮개 + out = compute_labor(got, SHEETS) + owner = next(r for r in out["daega"] if r["key"] == got["owners"]["pipe@100.0"]) + assert owner["blank"] >= 1 # 부른 SC 빈 줄 = 조합도 빈 줄(166-6) From 8cbbe41d99b892f0ff0d0077ffd7de19e3a0eb6f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 22:27:51 +0900 Subject: [PATCH 21/23] =?UTF-8?q?feat(723-3):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A4=84=20=EB=AA=A8=EB=8B=AC=20=3D=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A4=84=20=EB=AA=A8=EB=93=9C(=EB=B3=B4=EA=B8=B0=20=EB=84=B7)?= =?UTF-8?q?=20=E2=80=94=20[=EC=A0=81=EC=9A=A9]=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=EC=A4=84=20=EB=8D=AE=EA=B0=9C=20+=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=EB=A1=9C=EB=8F=84?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5(=EA=B8=B0=EB=B3=B8=20=EA=BA=BC=EC=A7=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `Labor_RowSk.ts` = 줄 → 이름 없는 SK 문서(lineDoc · 바탕 줄 정체) → 717-2b `mountSangeunDetail` 그대로 얹음 · take = 패치 {덮개, 뺀줄, 더한줄}(M02 변경 항목 · M01 안 바뀜) + 옵션(손댄 값만 · 자동값 안 남김) - `Labor_RowModal.ts` = 717-3 두 보기 갈음 · [적용] 뒤 닫힘 · [단가산출로도 저장] = 개인 층 새 SK(from-line) - `Sangeun_Detail.ts` handle 에 getValues 한 줄(브레인 허락 · 724 공용 부품이 살림) - `Labor_Table.ts` = 우클릭 메뉴 두 그림 뒤 엶(칸 초점 밀림에 닫히던 것) · 원문 패널 처음 접힘(표 넓게) · 머리 단추 묶음 - `Hopyo_Relink._total` = 엑셀 수량(본뜬.실무수량)으로 — 705-2 다시 잇기 전 · 후 금액 되살림(723-1 번짐) - 새 지킴 시험 test_723_labor_table(저장 수량 걷음 · 이은 알약 · 안 줄 알약 · 화면 이음) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Relink.py | 6 +- .../M02_MasterTemplete_Labor_RowModal.ts | 124 ++++++++++++------ .../M02_MasterTemplete_Labor_RowSk.ts | 108 +++++++++++++++ .../M02_MasterTemplete_Labor_Table.ts | 14 +- .../M02_MasterTemplete_Sangeun_Detail.ts | 3 +- resources/tester/test_175_precombo_mats.py | 2 +- resources/tester/test_723_labor_table.py | 100 ++++++++++++++ 7 files changed, 306 insertions(+), 51 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts create mode 100644 resources/tester/test_723_labor_table.py diff --git a/M02_MasterTemplete/M02_Hopyo_Relink.py b/M02_MasterTemplete/M02_Hopyo_Relink.py index bf2054d2c..0e5ac42bc 100644 --- a/M02_MasterTemplete/M02_Hopyo_Relink.py +++ b/M02_MasterTemplete/M02_Hopyo_Relink.py @@ -111,8 +111,12 @@ def rows(keys: list[str] | None = None, doc: dict[str, Any] | None = None) -> di def _total(key: str) -> float | None: + """1 단위 계 — 수량 = 불러온 때 엑셀 수량(`본뜬.실무수량` · 723-1 · 저장 수량 없음).""" + from M02_MasterTemplete.M02_Hopyo_Build import excel_given + at = refs.here() - got = refs.Expand(m01.loaded()[1])({"층": at[0], "주인": at[1], "키": key}) + ref = {"층": at[0], "주인": at[1], "키": key} + got = refs.Expand(m01.loaded()[1], given=excel_given(ref))(ref) return _num((got.get("기준") or {}).get("계")) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts index 55e61d9eb..395279114 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts @@ -1,14 +1,17 @@ /* ============================================================================= * M02_MasterTemplete_Labor_RowModal.ts - * 일위대가표 줄 누름 모달(PLAN 723-2 · 요구서_내역16 2장 4) — 일위대가표에서 빠진 줄 동작을 모달 안으로. - * · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션 · 메모](`Labor_Input` · 켜쌓기율) · - * 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`) · [단가산출 보기](717-3 `Labor_SangeunModal`) + * 일위대가표 줄 누름 모달(PLAN 723-2 · 723-3 · 요구서_내역16 2장 4 · 5) — 일위대가표에서 빠진 줄 동작을 모달 안으로. + * · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션](메모 · 켜쌓기율 · `Labor_Input`) · + * 단가산출 상세 줄 모드(717-2b 보기 넷 · `Labor_RowSk` · 717-3 두 보기 갈음) · + * 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`) + * · [적용] = 그 줄 덮개(M02 변경 항목) + 옵션(시험 입력 → 그 줄 입력값 · 규격 칸) · [단가산출로도 저장](기본 꺼짐) * · 사용자 자재 줄 = 자재 모달(세 칸 · `Labor_MatTable` 한 줄) * · 고침 = 그 줄 화면 캐시(저장은 [저장]) · 금액은 표가 다시 셈 → `watch` 로 받아 다시 그림 * ========================================================================== */ +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; +import { askConfirm, openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborCalc, LaborMat, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api"; @@ -16,7 +19,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; import { matTable } from "./M02_MasterTemplete_Labor_MatTable"; -import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal"; +import { mountRowSk, saveAsSk, type RowSkHandle } from "./M02_MasterTemplete_Labor_RowSk"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover"; import { releaseTied } from "./M02_Labor_LogicMats_Model"; @@ -35,17 +38,23 @@ export interface RowModalOptions { unit: string; scope: SkScope; readOnly: boolean; - /** 셈 주입(프로젝트 복사본) — [단가산출 보기] 없음 */ + /** 셈 주입(프로젝트 복사본) — 단가산출 상세 없음 */ inject: boolean; /** 사용자 자재 코드 — 로직 자재 [바꾸기]로 같은 품목 막음 */ userMats: string[]; watch: CalcWatch; /** 고침 뒤 — `again` = 서버 다시 셈(입력 · 고름 · 별도계상) · 아니면 화면만 */ done: (again: boolean) => void; - /** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로 */ - replace?: (row: LaborRow) => void; } +const TEXT = { + Apply: ["적용", "Apply"], + AlsoSk: ["단가산출로도 저장", "Also save as build-up"], + Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"], + Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"], +} as const; +const t = (k: keyof typeof TEXT): string => TEXT[k][0]; + export function openRowModal(o: RowModalOptions): void { const row = o.row; const edit = !o.readOnly; @@ -112,56 +121,89 @@ export function openRowModal(o: RowModalOptions): void { rate: row.켜쌓기율 ?? null, readOnly: o.readOnly, done: (next) => { - const values = o.logic ? keepKnown(o.logic.입력, next.입력값).kept : next.입력값; - const same = - JSON.stringify(values) === JSON.stringify(row.입력값) && - next.켜쌓기율 === (row.켜쌓기율 ?? null); - const before = row.입력값; - row.입력값 = values; - releaseTied(row, before, line()?.재료 ?? []); + const same = setValues(next.입력값) && next.켜쌓기율 === (row.켜쌓기율 ?? null); row.메모 = next.메모; if (next.켜쌓기율 === null) delete row.켜쌓기율; else row.켜쌓기율 = next.켜쌓기율; o.done(!same); draw(); + if (!same) skMount(); // 바뀐 옵션으로 상세 다시 }, }), }); option.dataset.rowOption = ""; - const sk = createButton({ - label: tc("Labor_Sangeun"), - variant: "ghost", - onClick: () => - openRowSangeun({ - scope: o.scope, - row, - title: o.title, - head: o.head, - qty: "", - unit: o.unit, - readOnly: o.readOnly, - apply: (p) => { - if (p) row.패치 = p; - else delete row.패치; - o.done(true); - }, - ...(o.replace ? { replace: o.replace } : {}), - }), + + /** 옵션 → 그 줄 입력값(로직 줄만 · 아는 입력만) · 같으면 참 */ + function setValues(next: Record): boolean { + if (row.참조) return true; + const values = o.logic ? keepKnown(o.logic.입력, next).kept : next; + if (JSON.stringify(values) === JSON.stringify(row.입력값)) return true; + const before = row.입력값; + row.입력값 = values; + releaseTied(row, before, line()?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1) + return false; + } + + // 단가산출 상세 줄 모드(723-3) — 셈 주입(프로젝트 복사본)엔 없음 + const skHost = el("div", { attrs: { "data-row-sk-host": "" } }); + let sk: RowSkHandle | null = null; + const skMount = (): void => { + if (o.inject) return; + sk?.destroy(); + sk = mountRowSk(skHost, { row, scope: o.scope, title: o.title, unit: o.unit, readOnly: !edit }); + }; + let close = (): void => {}; + const finish = (): void => { + stop(); + sk?.destroy(); + }; + const also = createCheckbox(t("AlsoSk"), { checked: false }); + also.input.dataset.rowAlsoSk = ""; + const apply = createButton({ + label: t("Apply"), + variant: "filled", + onClick: async () => { + const got = sk?.take() ?? null; + if (got) { + if (got.patch) row.패치 = got.patch; + else delete row.패치; + setValues(got.values); + o.done(true); + draw(); + } + if (also.input.checked) { + try { + const name = await saveAsSk(row, got?.patch ?? row.패치 ?? null, o.title, o.unit); + showToast(t("Saved").replace("{k}", name), "success"); + } catch (error) { + return showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + finish(); // [적용] = 그 줄에 남기고 닫음(저장은 페이지 [저장]) · 코드 닫기는 beforeClose 안 거침 + close(); + }, }); - sk.dataset.rowSangeun = ""; + apply.dataset.rowApply = ""; const stop = o.watch((c) => { calc = c; draw(); }); - openModal({ + const tools = edit && !o.inject ? [also.root, apply] : []; + ({ close } = openModal({ title: o.title, closeLabel: tc("In_Close"), - width: "960px", - actions: [...(row.참조 ? [] : [option]), ...(o.inject ? [] : [sk])], - beforeClose: () => (stop(), true), - mount: (body) => body.append(info, mats), - }); + width: "1100px", + wide: true, + actions: [...(row.참조 || !edit ? [] : [option]), ...tools], + beforeClose: async () => { + if (sk?.take() && !(await askConfirm({ message: t("Discard"), danger: true }))) return false; + finish(); + return true; + }, + mount: (body) => body.append(info, skHost, mats), + })); draw(); + skMount(); } export interface MatModalOptions { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts new file mode 100644 index 000000000..1b8839822 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts @@ -0,0 +1,108 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_RowSk.ts + * 일위대가 줄 모달 안 단가산출 상세 = 717-2b 단가산출(SK) 상세 「줄 모드」(PLAN 723-3 · 정함 4 · 5). + * · 줄 → 이름 없는 SK 문서(`lineDoc` · 고름 · 자재표시 = 바탕 줄 정체 cid) → `mountSangeunDetail` 그대로 얹음 + * (보기 넷 · 품 · 단가 덮개 · 줄 빼기 · 더하기 · 시험 입력 칩) + * · `take()` = [적용] 때 그 줄에 남길 것 — 패치 `{덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜 · 내역 17-2) + + * 옵션(시험 입력 → 그 줄 `입력값` · 정함 5) + * · [단가산출로도 저장](기본 꺼짐) = `saveAsSk` — 개인 층 새 SK(`from-line`) + * 공용 부품 갈아 끼움은 724(sub2) — 지금은 717-2b 상세를 그대로 씀. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; +import { fetchAuto } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import { calcSangeun, sangeunFromLine, type SkScope } from "./M02_MasterTemplete_Sangeun_Cover"; +import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; +import { + cleanPatch, + lineDoc, + needsIds, + type LinePatch, + type SkCalc, +} from "./M02_Sangeun_Cover_Model"; + +export interface RowSkHandle { + /** 고친 것(패치 · 옵션) — 없으면 null */ + take: () => { patch: LinePatch | null; values: Record } | null; + destroy: () => void; +} + +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** 줄 모드 SK 상세를 `host` 에 — 바탕 줄 정체가 필요하면 먼저 셈 */ +export function mountRowSk( + host: HTMLElement, + o: { row: LaborRow; scope: SkScope; title: string; unit: string; readOnly: boolean }, +): RowSkHandle { + let handle: ReturnType | null = null; + let alive = true; + /** 자동값(상세가 빈 입력에 채움) — 손대지 않은 자동값은 줄에 안 남김(빈 값 = 자동값 그대로) */ + let auto: Record = {}; + const before = cleanPatch(o.row.패치); + const values0 = Object.fromEntries( + Object.entries(o.row.입력값 ?? {}).map(([k, v]) => [k, String(v)]), + ); + host.replaceChildren(createText("…", "muted")); + void (async () => { + let ids: string[] | null = null; + try { + if (needsIds(o.row)) + ids = (await calcSangeun(o.scope, lineDoc(o.row, null, null))).정체 ?? null; + } catch { + ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임) + } + if (!alive) return; + const doc = { ...lineDoc(o.row, ids, before), 이름: o.title, 단위: o.unit || "식" }; + const box = el("div", { attrs: { "data-row-sk": "" } }); + host.replaceChildren(box); + handle = mountSangeunDetail(box, doc, { + layer: o.scope.layer, + owner: o.scope.owner, + readOnly: o.readOnly, + onChanged: () => undefined, + }); + if (o.row.로직 && !o.row.참조) auto = await fetchAuto(o.row.로직).catch(() => ({})); + })(); + return { + take: () => { + if (!handle) return null; + const d = handle.getDoc(); + const patch = cleanPatch({ + 덮개: d.덮개 as LinePatch["덮개"], + 뺀줄: d.뺀줄, + 더한줄: d.더한줄, + }); + const values = o.row.참조 + ? values0 + : Object.fromEntries( + Object.entries(handle.getValues()).filter( + ([k, v]) => + String(v).trim() !== "" && + (k in values0 || auto[k] == null || String(auto[k]) !== String(v)), + ), + ); + return same(patch, before) && same(values, values0) ? null : { patch, values }; + }, + destroy: () => { + alive = false; + handle?.destroy(); + }, + }; +} + +/** [단가산출로도 저장] — 그 줄(고친 패치 포함)을 개인 층 새 SK 로 · 새 이름 돌려줌 */ +export async function saveAsSk( + row: LaborRow, + patch: LinePatch | null, + name: string, + unit: string, +): Promise { + const line: Record = { ...row }; + delete line.패치; + delete line.수량; + if (patch) line.패치 = patch; + const got = await sangeunFromLine({ layer: "personal", owner: null }, line, name, unit || "식"); + return got.이름; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts index c93137a80..7fdd3e9f0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -114,7 +114,8 @@ export function mountLaborTable( }); const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); const side = createSidePanels({ - prefKey: "m02-labor-side", + prefKey: "m02-labor-table-side", // 일위대가표는 넓게 — 원문은 처음 접힘(누르면 엶) + defaultOpen: null, follow: false, panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], onChange: () => showSource(), @@ -175,7 +176,8 @@ export function mountLaborTable( const n = tr ? at(Number(tr.dataset.i)) : null; if (n === null || !edit) return; ev.preventDefault(); - openTreeMenu(ev, menuAt(n)); + // 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶 + requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n)))); }); gridHost.replaceChildren(grid.root); showSource(); @@ -282,10 +284,6 @@ export function mountLaborTable( inject: !!opt.calc, userMats: cur.자재.map((m) => m.코드), done: (again) => changed(!again), - replace: - edit && opt.layer === "personal" && !library.owner - ? (next: LaborRow) => ((cur.담은로직[n] = next), changed()) - : undefined, }); } function openMat(i: number): void { @@ -452,7 +450,9 @@ export function mountLaborTable( className: "m01-logic__section-head", children: [ el("h3", { text: tc("Grid_Title") }), - ...(edit ? [addLogic, addMat] : []), + ...(edit + ? [el("div", { className: "ui-btn-group", children: [addLogic, addMat] })] + : []), ], }), createText(t("TempHint"), "hint"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 3bbbde1dc..5402555a8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -58,7 +58,7 @@ export function mountSangeunDetail( host: HTMLElement, doc0: unknown, opt: SangeunDetailOptions, -): { getDoc: () => SkDoc; destroy: () => void } { +): { getDoc: () => SkDoc; getValues: () => Record; destroy: () => void } { setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1) const doc = clone((doc0 ?? {}) as SkDoc); let saved = JSON.stringify(doc); @@ -318,6 +318,7 @@ export function mountSangeunDetail( saved = JSON.stringify(doc); return clone(doc); }, + getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김 destroy: () => { alive = false; host.replaceChildren(); diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index bfa0df359..43f350d47 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -43,7 +43,7 @@ def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # n ) modal = read("M02_MasterTemplete_Labor_RowModal.ts") assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail - assert "...(edit ? [addLogic, addMat] : [])" in lib + assert 'className: "ui-btn-group", children: [addLogic, addMat]' in lib assert "setLogicMark(row, at, key, v);" in modal and "setMark(o.mat, key, v);" in modal assert "delete out.자재" not in lib and "delete out.열" in lib diff --git a/resources/tester/test_723_labor_table.py b/resources/tester/test_723_labor_table.py new file mode 100644 index 000000000..7dd002705 --- /dev/null +++ b/resources/tester/test_723_labor_table.py @@ -0,0 +1,100 @@ +"""PLAN 723 — M02 일위대가(사전조합) 수량 = 저장 안 함 · 일위대가표만 · 줄 모달(내역 16 · 17). + +723-1 서버 — 저장 때 안 줄 · 자재 수량 null(비율 줄 율만) · 펼침 = 쓰는 쪽 알약 값 · 안 이으면 「수량 없음」 · +구조물 조합 알약에 안 줄 알약 · 불러오기 엑셀 수량 = `본뜬.실무수량`(셈 안 씀). +723-2 · 723-3 화면 — 서고 상세 = `Labor_Table`(수량 칸 휘발 임시 입력 · 우클릭) · 줄 모달 = 717-2b 상세 줄 모드. +""" + +import os + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture + +SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "M02_MasterTemplete") +SYS = {"층": "system", "주인": None} +STONE = "GF000219" # 원/㎡ +RATE = {"이름": "공구손료", "단위": "%", "단가": {"노무비": 0, "재료비": 0, "경비": 0}} + + +def _src(name: str) -> str: + return open(os.path.join(SRC, name), encoding="utf-8").read() + + +def test_저장은_안_줄_수량을_걷고_비율_줄_율만_남김(world): # noqa: F811 + rate = {"고정": {**RATE, "비율": {"기준": "노무비", "비목": "경비"}}, "수량": {"손": 3}} + lines = [{"로직": STONE, "수량": {"손": 2.5}}, rate] + with store.at(world["system"]): + key = refs.create("precombo", {"이름": "수량 없음", "단위": "m", "담은로직": lines})["이름"] + doc = store.read("precombo", key)["문서"] + assert doc["담은로직"][0]["수량"] is None + assert doc["담은로직"][1]["수량"] == {"손": 3} # 비율 = 율(기준단위 당 수량 아님) + frozen = refs.with_pills(doc) # 프로젝트 굳힘도 수량 없이 + assert frozen["담은로직"][0]["수량"] is None and frozen["알약"] + + +def test_펼침은_이은_알약만_쓰고_층_원본_옛_손은_안_씀(world): # noqa: F811 + with store.at(world["system"]): + key = refs.create("precombo", {"이름": "옛", "단위": "m", "담은로직": [{"로직": STONE}]})[ + "이름" + ] + ref = {**SYS, "키": key} + path = world["system"] / "precombo" / f"{key}.json" + import json + + old = json.loads(path.read_bytes()) + old["담은로직"][0]["수량"] = {"손": 9} # 옛 문서(723-1 앞) — 파일은 그대로 둠 + path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8") + whole = m01.loaded()[1] + with store.at(world["system"]): + bare = refs.Expand(whole)(ref) + got = refs.Expand(whole, given={f"{refs.ref_id(ref)}#0": 2})(ref) + frozen = refs.Expand(whole, read=lambda r: old)(ref) # 굳힌 사본 = 손 그대로 + stone = labor.run_line(whole, STONE, {})["기준"]["계"] + assert bare["기준"] is None and "수량 없음" in bare["까닭"] + assert got["기준"]["계"] == pytest.approx(stone * 2) + assert frozen["기준"]["계"] == pytest.approx(stone * 9) + + +def test_구조물_조합_알약에_안_줄_알약(world): # noqa: F811 + with store.at(world["system"]): + key = refs.create( + "precombo", {"이름": "옹벽", "단위": "m", "담은로직": [{"로직": STONE, "별칭": "돌"}]} + )["이름"] + doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}}]}) + pills = labor.pills(doc) + rid = refs.ref_id({**SYS, "키": key}) + assert [p["키"] for p in pills] == [rid, f"{rid}#0"] + assert pills[1]["이름"] == "옹벽 · 돌" and pills[1]["단위"] == "㎡" + + +def test_화면_서고_상세는_일위대가표만_임시_수량_휘발(): + table, detail = ( + _src("M02_MasterTemplete_Labor_Table.ts"), + _src("M02_MasterTemplete_Labor_Detail.ts"), + ) + assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail + assert "const temp = new Map();" in table # 휘발 — 문서 밖 + assert "calc = calc && settle(calc, withTemp(), {});" in table # 짝 셈 미리 보기 + assert ( + 'const out: Record = { ...cur, 종류: "사전조합" };' in table + ) # temp 안 실음 + for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'): + assert label in table # 우클릭 메뉴 + assert "fixed: (fx: LaborFixed, name: string)" in table # 721-2 고정 줄 이음 + + +def test_화면_줄_모달은_SK_상세_줄_모드_적용은_덮개와_옵션(): + modal, sk = ( + _src("M02_MasterTemplete_Labor_RowModal.ts"), + _src("M02_MasterTemplete_Labor_RowSk.ts"), + ) + detail = _src("M02_MasterTemplete_Sangeun_Detail.ts") + assert "mountSangeunDetail(box, doc," in sk and "getValues: () => ({ ...values })" in detail + assert "if (got.patch) row.패치 = got.patch;" in modal and "setValues(got.values);" in modal + assert 'createCheckbox(t("AlsoSk"), { checked: false })' in modal # 기본 꺼짐 + assert "openRowSangeun" not in modal # 717-3 두 보기 갈음 From 5bd2df81a4a7954bb943f08132dc7a542177d840 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 22:32:31 +0900 Subject: [PATCH 22/23] =?UTF-8?q?test(716-3):=20M02=20=EC=99=BC=EC=AA=BD?= =?UTF-8?q?=20=EC=B9=B8=20=EC=B0=A8=EB=A1=80=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20-=20=EB=8F=84=EB=A9=B4=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EC=B9=B8=EC=9D=B4=20720-4=20=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=97=86=EC=9D=B4=20=EB=86=93=EC=97=AC=20=EC=B0=BE?= =?UTF-8?q?=EB=8A=94=20=EA=B8=80=20drawing=20=3F=20=EB=A5=BC=20drawing,=20?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_m02_precombo_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 9aeeab33c..ab7e412bc 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -154,7 +154,7 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7 "summary,", "createDivider()", "hopyo.title", - "drawing ?", + "drawing,", "practice ?", ] at = [order.index(m) for m in marks] From 6db98d6b1450c126b5b9bf734a17f33d0b7bb8e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 22:44:40 +0900 Subject: [PATCH 23/23] =?UTF-8?q?feat(643-1):=20B04=20implicit=20=EB=93=B1?= =?UTF-8?q?=EA=B3=A0=EC=84=A0=20=3D=20=EA=B5=B3=ED=9E=8C=20=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=E2=80=94=20=EB=93=B1?= =?UTF-8?q?=EA=B3=A0=EC=84=A0=20=C2=B7=20=EC=A2=85=EB=8B=A8=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EA=B0=99=EC=9D=80=20=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implicit 등고선이 RBF 를 다시 세던 길 대신 implicit_{필터}_grid.npz 를 바로 씀(같은 dtm 없을 때만 옛 길) - 용화(검증_sub4) WF1 다시 = classification + implicit 자동 확정 · 등고선 꼭짓점 − 표본 면 최대 3 mm(전 1.28 m) · 등고선 16.7 s - 지킴 시험 test_641_1 에 등고선 = 표본 면 더함 Co-Authored-By: Claude Opus 5.5 --- B04_PreProcess/B04_PreProcess_Engine_Contour.py | 14 ++++++++++++++ resources/tester/test_641_1_implicit_grid.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Contour.py b/B04_PreProcess/B04_PreProcess_Engine_Contour.py index 7ede4004d..30a6946a1 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Contour.py @@ -428,6 +428,20 @@ def extract_contours( ) if representation == "local_rbf_height_field": + # 굳힌 격자(641-1)가 있으면 그 면 그대로 — 등고선 = 서버 표본 · 종횡단과 같은 면(643) + filter_key = model_npz_path.stem.removeprefix("implicit_") + if (model_npz_path.parent / f"dtm_{filter_key}.npz").is_file(): + from common_util.common_util_surface_sampler import implicit_grid_npz + + with np.load(implicit_grid_npz(model_npz_path.parent, filter_key)) as grid: + return extract_contours_from_grid( + grid["x"], + grid["y"], + grid["z"], + grid["valid_mask"], + interval, + scene_center=scene_center, + ) centers_xy, center_z = data["centers_xy"], data["center_z"] smoothing = float(data["smoothing"][0]) interpolator = RBFInterpolator( diff --git a/resources/tester/test_641_1_implicit_grid.py b/resources/tester/test_641_1_implicit_grid.py index 1609f5a59..66d93c38f 100644 --- a/resources/tester/test_641_1_implicit_grid.py +++ b/resources/tester/test_641_1_implicit_grid.py @@ -161,3 +161,17 @@ def test_B07_음영_implicit_확정이면_평활_DTM(tmp_path, monkeypatch): ): basin._hillshade_for_box(SimpleNamespace(surface_params=params, project_root=tmp_path), box) assert opened[-1] == name + + +def test_643_등고선_굳힌_격자_그대로(tmp_path): + from B04_PreProcess.B04_PreProcess_Engine_Contour import extract_contours + + models = _project(tmp_path) + contours = extract_contours( + models / "implicit_classification.npz", "local_rbf_height_field", 1.0 + ) + points = np.array([p for c in contours for p in c["coordinates"]]) + sampler = build_surface_sampler(models, "classification", "implicit", False) + z, valid = sampler.sample_xy(points[:, :2]) + assert valid.sum() > 0.9 * len(points) + assert np.abs(z[valid] - points[valid, 2]).max() < 0.005 # 등고선 = 표본 면(좌표 mm 자리)