diff --git a/수량산출/README.md b/수량산출/README.md index 0173972..c784bdc 100644 --- a/수량산출/README.md +++ b/수량산출/README.md @@ -1,5 +1,7 @@ # 교각 수량산출 +**현재 출력 방식:** `전체 교각 입력`에서 교각별 미입력값을 함께 입력하고, Excel 출력은 전체 교각별 산출서와 합산 집계표를 생성합니다. `수량산출_ex)` 예시·미사용 시트는 결과에서 제외합니다. [전체 교각 개선 보고서](docs/PROJECT_TAKEOFF_REPORT.md)를 참고하세요. 아래의 초기 단일 교각 검증 기록과 현재 출력 시트 수는 다를 수 있습니다. + `5. 재료표` 탭의 **교각 수량산출**에서 현재 생성된 교각을 선택합니다. 기존 철근재료표 메뉴는 그대로 유지합니다. 1. 자동 입력값을 확인합니다. 길이/EL은 m, 본수는 개, 횡경사는 %입니다. diff --git a/수량산출/bearing-dimensions.test.ts b/수량산출/bearing-dimensions.test.ts new file mode 100644 index 0000000..8f5fc61 --- /dev/null +++ b/수량산출/bearing-dimensions.test.ts @@ -0,0 +1,11 @@ +import {it,expect} from 'vitest'; +import {bearingDimensions} from './bearing-dimensions'; +import type {PierModel} from '../src/core/geometry'; +it('받침 본체는 3D 형상의 솔플레이트 여유와 양측 100mm 공제를 동일하게 적용한다',()=>{ + const m={blocks:[{plan:{A:700,B:640,margin:100}}]} as PierModel; + expect(bearingDimensions(m).length).toBeCloseTo(.6);expect(bearingDimensions(m).width).toBeCloseTo(.54); +}); +it('서로 다른 받침 치수나 형상 미생성은 단일 받침 치수로 추정하지 않는다',()=>{ + expect(bearingDimensions({blocks:[{},{}]} as PierModel)).toEqual({length:null,width:null}); + expect(bearingDimensions({blocks:[{plan:{A:700,B:640,margin:100}},{plan:{A:800,B:640,margin:100}}]} as PierModel)).toEqual({length:null,width:.54}); +}); diff --git a/수량산출/bearing-dimensions.ts b/수량산출/bearing-dimensions.ts new file mode 100644 index 0000000..84eb744 --- /dev/null +++ b/수량산출/bearing-dimensions.ts @@ -0,0 +1,7 @@ +import type {PierModel} from '../src/core/geometry'; +/** Same bearing-body dimensions as src/view/scene.ts; A/B alone are solplate inputs. */ +export function bearingDimensions(model:PierModel){ + const plans=model.blocks.map(b=>b.plan); + const uniform=(values:(number|undefined)[])=>values.length&&values.every(v=>v!=null&&Number.isFinite(v)&&v>0&&Math.abs(v-values[0]!)<1e-7)?values[0]!:null; + return {length:uniform(plans.map(p=>p?(p.A+p.margin)/1000-.2:undefined)),width:uniform(plans.map(p=>p?(p.B+p.margin)/1000-.2:undefined))}; +} diff --git a/수량산출/docs/MAPPING.md b/수량산출/docs/MAPPING.md index a9eb716..5768995 100644 --- a/수량산출/docs/MAPPING.md +++ b/수량산출/docs/MAPPING.md @@ -24,8 +24,8 @@ |말뚝 두께|Tp|m|3.2 관련변수!N37|자동|입력 말뚝두께 mm→m| |말뚝 길이|Lp|m|3.2 관련변수!N38|자동|말뚝 상면-하면| |말뚝 수|Np|개|3.2 관련변수!N39|자동|model.piles.length| -|받침 길이|Lb|m|3.2 관련변수!N40|직접 입력/검토|받침 plan.A mm→m| -|받침 폭|Bb|m|3.2 관련변수!N41|직접 입력/검토|받침 plan.B mm→m| +|받침 길이|Lb|m|3.2 관련변수!N40|직접 입력/검토|3D 받침 본체: (plan.A+margin)/1000−0.2 m| +|받침 폭|Bb|m|3.2 관련변수!N41|직접 입력/검토|3D 받침 본체: (plan.B+margin)/1000−0.2 m| |받침 수|Nb|개|3.2 관련변수!N42|자동|model.blocks.length| |받침 블럭 길이|Lbc|m|3.2 관련변수!N43|자동|모델 분리블럭 크기| |받침 블럭 폭|Bbc|m|3.2 관련변수!N44|자동|모델 분리블럭 크기| diff --git a/수량산출/docs/PROJECT_TAKEOFF_REPORT.md b/수량산출/docs/PROJECT_TAKEOFF_REPORT.md new file mode 100644 index 0000000..02b030f --- /dev/null +++ b/수량산출/docs/PROJECT_TAKEOFF_REPORT.md @@ -0,0 +1,40 @@ +# 전체 교각 입력·산출서·집계 개선 + +## 사용법 + +`5. 재료표 → 교각 수량산출 → 전체 교각 입력`에서 모든 교각을 행으로 확인한다. 기본적으로 미입력 변수만 모아 보여주며 체크를 해제하면 자동값·원본 기본값·직접값을 모두 확인할 수 있다. Excel 표를 붙여넣을 수 있으며 값은 각 교각의 기존 `quantityTakeoff` 필드에 저장된다. 입력 후 교각별 기준 검토를 체크하고 전체 재계산 및 전체 교각 Excel 출력을 실행한다. 대시보드의 Excel 출력도 전체 프로젝트를 대상으로 한다. + +미입력·형상 미생성·적용 범위 문제가 있으면 해당 교각과 사유를 표시하고 불완전한 수량을 완료 Excel로 만들지 않는다. 기존 다기둥·꺾인 교각 등 원본 산식 적용 제한은 유지한다. 원본 집계표가 같은 규격을 전제로 하므로 교각별 재료 규격이 서로 다르면 규격 분리 검토를 안내한다. + +## 산출서 구성 + +- 표지 및 목차 +- 전체 수량집계: 원본 53개 공종 행에 실제 교각별 수량을 참조하고 SUM으로 합산 +- 원본 3.1 산출기준 3개 시트 +- 각 실제 교각별 3.2 변수 시트와 3.3 수량산출서 + +`수량산출_ex)`와 고정 P2~P6 미사용 시트를 결과에서 제거한다. 교각 수에 맞춰 산출서·변수 시트를 생성하고 집계 열을 확장한다. 각 상세 산식은 해당 교각의 변수 시트만 참조하도록 바꾼다. 원본 수량 계산식·스타일·그림은 기존 OOXML 보존 출력을 활용한다. 미리보기에서도 예시/미사용 시트를 숨기고 수량집계를 전체 교각 결과로 표시한다. 원본 Excel은 변경하지 않는다. + +## 받침 길이·폭 + +기존 매핑은 `plan.A/B`를 그대로 사용했지만 이는 솔플레이트 입력이다. 실제 3D 받침 본체 생성 코드와 같은 `(A + margin)/1000 - 0.2`, `(B + margin)/1000 - 0.2` m로 연결했다. 실제 받침 형상이 없거나 한 교각 내 치수가 달라 단일값을 정할 수 없으면 자동 치수를 추정하지 않는다. 교좌면 1.2m·몰탈 1m를 받침 치수로 대체하지 않는다. + +## 주요 변경 파일 + +- `project-inputs.ts`: 전체 교각 입력 표, 붙여넣기, 저장·재계산·출력 진입 +- `bearing-dimensions.ts`, `model.ts`: 실제 받침 본체 제원 연결 +- `workspace.ts`, `workspace.css`: 전체 교각 작업 흐름과 모달 +- `excel-preview.ts`, `review-panel.ts`: 예시 제거, 실제 전체 집계 미리보기 +- `server.ts`, `tools/project_workbook.py`: 가변 교각 수의 원본 양식 합성 출력 +- `bearing-dimensions.test.ts`, `tools/verify_project_package.py`: 형상 치수 및 전체 합산 검증 + +## 검증 결과 + +- 실제 운곡교 6개 교각: 전체 미입력 입력·저장·재계산·출력, 예시 제거, 브라우저 오류 0건. +- 실제 출력은 17시트, 상세 6개. 53개 집계행과 318개 상세 참조·계산캐시 차이 0건. +- 17교각 **합성 확장 시험**: 39시트, 53개 집계행과 901개 상세 참조·합계 차이 0건. 실제 산외교 수량 검증을 뜻하지 않는다. +- TypeScript 검사, 수량산출 테스트 12개, Vite 빌드 통과. 기존 번들 크기 경고 유지. +- 실제 Microsoft Excel 앱의 재계산까지 검증한 것은 아니다. ZIP·XML·관계·원식 참조·캐시 검증을 수행했다. +- 검증 입력의 토층·미생성 받침 치수는 원본 예시값을 사용한 시험 조건이다. 실무 확정값으로 사용하지 않는다. + +검증 근거: `project-package-verification.json`. 원본에 없는 수량 규칙은 이번 UI·출력 개선을 이유로 추가하지 않았다. diff --git a/수량산출/docs/project-package-verification.json b/수량산출/docs/project-package-verification.json new file mode 100644 index 0000000..2dc2aaa --- /dev/null +++ b/수량산출/docs/project-package-verification.json @@ -0,0 +1,8 @@ +{ + "piers": 6, + "sheets": 17, + "summary_rows_checked": 53, + "source_formula_links": 318, + "example_removed": true, + "cache_differences": 0 +} \ No newline at end of file diff --git a/수량산출/excel-preview.ts b/수량산출/excel-preview.ts index a71e459..72d4221 100644 --- a/수량산출/excel-preview.ts +++ b/수량산출/excel-preview.ts @@ -16,12 +16,12 @@ const referenceImages:Record={ }; const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):''; const node=(tag:K,text?:string)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;return n;}; -export function createExcelPreview(options:{result:ReturnType;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};plan?:{selected:PierModel;models:readonly PierModel[]};compact?:boolean;state:{sheet:string;zoom:number};onCell:(cell:string)=>void}):HTMLElement{ +export function createExcelPreview(options:{result:ReturnType;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};projectRows?:{name:string;result:ReturnType}[];plan?:{selected:PierModel;models:readonly PierModel[]};compact?:boolean;state:{sheet:string;zoom:number};onCell:(cell:string)=>void}):HTMLElement{ const {result,stored,pier,state}=options,host=node('section');host.className='takeoff-excel-preview'+(options.compact?' is-compact':'');const toolbar=node('div');toolbar.className='takeoff-excel-toolbar'; const selector=node('select');selector.setAttribute('aria-label',options.compact?'미니 산출서 시트 선택':'Excel 샘플 시트 선택'); - const names=Object.keys(layouts);for(const name of ['__all__',...names]){const o=node('option',name==='__all__'?'표지부터 전체 보기 (13시트)':name===DETAIL?'3.3 수량산출_'+pier:name);o.value=name;o.selected=state.sheet===name;selector.append(o);}toolbar.append(selector); + const names=Object.keys(layouts).filter(name=>name!=='3.3 수량산출_ex)'&&!/^3\.3 수량산출_P[2-6]$/.test(name));for(const name of ['__all__',...names]){const o=node('option',name==='__all__'?'표지부터 선택 교각 전체 보기':name===DETAIL?'3.3 수량산출_'+pier:name);o.value=name;o.selected=state.sheet===name;selector.append(o);}toolbar.append(selector); const previous=node('button',options.compact?'‹':'이전 시트'),next=node('button',options.compact?'›':'다음 시트');previous.setAttribute('aria-label','이전 시트');next.setAttribute('aria-label','다음 시트');previous.type=next.type='button';const counter=node('output');counter.className='takeoff-excel-page-count';toolbar.append(previous,counter,next); - const zoom=node('select');zoom.setAttribute('aria-label','수량산출서 확대');for(const z of options.compact?[.3,.4,.5,.6,.8,1]:[.8,1,1.1,1.25,1.5]){const o=node('option',Math.round(z*100)+'%');o.value=String(z);o.selected=z===state.zoom;zoom.append(o);}toolbar.append(zoom,node('span','원본 Excel 양식 · 수량 셀을 누르면 산식 확인')); + const zoom=node('select');zoom.setAttribute('aria-label','수량산출서 확대');for(const z of [0,...(options.compact?[.3,.4,.5,.6,.8,1]:[.8,1,1.1,1.25,1.5])]){const o=node('option',z===0?'한 장 맞춤':Math.round(z*100)+'%');o.value=String(z);o.selected=z===state.zoom;zoom.append(o);}toolbar.append(zoom,node('span','원본 Excel 양식 · 수량 셀을 누르면 산식 확인')); const viewport=node('div');viewport.className='takeoff-excel-viewport';const paper=node('div');paper.className='takeoff-excel-paper';viewport.append(paper);host.append(toolbar,viewport); const overrides:Overrides={...result.overrides}; for(let i=2;i<=6;i++){const s='3.3 수량산출_P'+i;for(const [cell,c]of Object.entries(template[s]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))overrides[cellKey(s,cell)]=0;const col=['P','R','T','V','X'][i-2];for(let r=18;r<=46;r++)overrides[cellKey(INPUTS,col+r)]=0;} @@ -32,19 +32,26 @@ export function createExcelPreview(options:{result:ReturnType; if(/^3\.3 수량산출_P[2-6]$/.test(sheet)){if(cell==='F2')return '(미사용)';const raw=template[sheet]?.[cell]?.value;if(typeof raw==='number'||typeof raw==='string'&&raw.startsWith('=')&&raw.length>1)return '';} if(sheet===DETAIL){if(cell==='F2')return '('+pier+')';const spec=specifications.find(v=>v.cell===cell);if(spec)return stored.values[spec.key]||'규격 확인 필요';const row=result.results.find(r=>r.cell===cell);if(row)return row.value;} if(sheet===INPUTS){if(cell==='N30'&&result.copingMode==='straight')return '직선';if(cell==='N17')return pier;if(/^[PRTVX](1[89]|[234]\d)$/.test(cell))return '';if(/^[PRTVX]17$/.test(cell))return '미사용';if(/^N\d+$/.test(cell)&&Object.hasOwn(result.overrides,cellKey(INPUTS,cell)))return result.overrides[cellKey(INPUTS,cell)];} - if(sheet==='1. 수량집계'){if(cell==='N4')return pier;if(/^(Q|T|W|Z|AC)4$/.test(cell))return '미사용';} + if(sheet==='1. 수량집계'&&options.projectRows){const match=cell.match(/^([A-Z]+)(\d+)$/)!;let col=0;for(const letter of match[1])col=col*26+letter.charCodeAt(0)-64;const row=Number(match[2]),source=template[sheet]['N'+row],ref=String(source?.value??'').match(/!\$?([A-Z]+)\$?(\d+)/),values=ref?options.projectRows.map(p=>p.result.results.find(r=>r.cell===ref[1]+ref[2])?.value??null):[]; + if(col>=14){const p=options.projectRows[Math.floor((col-14)/3)];if(!p)return '';if(row===4||row===48)return p.name;if(ref)return values[Math.floor((col-14)/3)];} + if(col===11&&ref)return values.some(v=>v===null)?null:values.reduce((a,b)=>a!+b!,0); + } const c=template[sheet]?.[cell];if(!c)return '';if(typeof c.value==='string'&&c.value.startsWith('=')&&c.value.length>1){try{return book.cell(sheet,cell);}catch{return null;}}return c.value; }; const formatted=(value:unknown,format:string)=>{if(value===null)return '미산정';if(value===undefined)return '';if(typeof value!=='number')return String(value);if(format==='General')return String(value); if(value===0&&format.includes('"-"'))return '−';const m=format.split(';')[0].match(/\.([0#]+)/),digits=m?.[1].length??0; return value.toLocaleString('ko-KR',{minimumFractionDigits:digits,maximumFractionDigits:digits,useGrouping:format.includes(',')});}; - const draw=()=>{state.sheet=selector.value;state.zoom=Number(zoom.value);paper.replaceChildren();paper.style.zoom=String(state.zoom);counter.textContent=state.sheet==='__all__'?'전체 / 13':(names.indexOf(state.sheet)+1)+' / '+names.length; + const fit=()=>{if(state.zoom!==0||!host.isConnected)return;paper.style.zoom='1';const css=getComputedStyle(viewport),w=viewport.clientWidth-parseFloat(css.paddingLeft)-parseFloat(css.paddingRight)-4,h=viewport.clientHeight-parseFloat(css.paddingTop)-parseFloat(css.paddingBottom)-4;paper.style.zoom=String(Math.max(.05,Math.min(w/paper.offsetWidth,h/paper.offsetHeight,1.5)));viewport.scrollTo(0,0);}; + const observer=new ResizeObserver(()=>{if(host.isConnected)fit();else observer.disconnect();});observer.observe(viewport); + const draw=()=>{state.sheet=selector.value;state.zoom=Number(zoom.value);paper.replaceChildren();paper.style.zoom=String(state.zoom||1);viewport.classList.toggle('is-page-fit',state.zoom===0);counter.textContent=state.sheet==='__all__'?'전체 / '+names.length:(names.indexOf(state.sheet)+1)+' / '+names.length; const renderSheet=(sheet:string)=>{ const page=node('article');page.className='takeoff-excel-page';page.dataset.previewSheet=sheet; const title=sheet===DETAIL?'3.3 수량산출 · '+pier:sheet==='3.3 수량산출_ex)'?sheet+' · 원본 참고 예시':/^3\.3 수량산출_P[2-6]$/.test(sheet)?sheet+' · 미사용':sheet; page.append(node('h4',title));paper.append(page); - const layout=layouts[sheet],table=node('table');table.className='takeoff-excel-sheet';table.dataset.sheet=sheet;table.style.width=layout.widths.reduce((a,b)=>a+b,0)+'px'; - if(referenceImages[sheet]){for(const [i,src]of referenceImages[sheet].entries()){const image=node('img');image.src=src;image.alt=sheet+' · 원본 PDF '+(i+1)+'페이지';image.className='takeoff-reference-page';image.style.width=table.style.width;page.append(image);}return;} + let layout=layouts[sheet]; + if(sheet==='1. 수량집계'&&options.projectRows){const n=options.projectRows.length,widths=[...layout.widths.slice(0,13),...Array.from({length:n},()=>layout.widths.slice(13,16)).flat()],cells={...layout.cells},merges=layout.merges.filter(m=>m[3]<14);for(let i=0;im[1]>=14&&m[3]<=16))merges.push([m[0],m[1]+3*i,m[2],m[3]+3*i]);for(let r=1;r<=layout.rows;r++)for(let c=0;c<3;c++)cells[letters(14+3*i+c)+r]=layout.cells[letters(14+c)+r];}layout={...layout,widths,cols:widths.length,cells,merges};} + const table=node('table');table.className='takeoff-excel-sheet';table.dataset.sheet=sheet;table.style.width=layout.widths.reduce((a,b)=>a+b,0)+'px'; + if(referenceImages[sheet]){for(const [i,src]of referenceImages[sheet].entries()){const image=node('img');image.src=src;image.onload=fit;image.alt=sheet+' · 원본 PDF '+(i+1)+'페이지';image.className='takeoff-reference-page';image.style.width=table.style.width;page.append(image);}return;} const cols=node('colgroup');for(const w of layout.widths){const c=node('col');c.style.width=w+'px';cols.append(c);}table.append(cols); const merged=new Map(),covered=new Set();for(const [r,c,re,ce]of layout.merges){merged.set(r+','+c,[r,c,re,Math.min(ce,layout.cols)]);for(let y=r;y<=re;y++)for(let x=c;x<=ce;x++)if(y!==r||x!==c)covered.add(y+','+x);} for(let r=layout.firstRow;r<=layout.rows;r++){const tr=node('tr');tr.style.height=layout.heights[r-1]+'px';for(let c=1;c<=layout.cols;c++){ @@ -65,7 +72,7 @@ export function createExcelPreview(options:{result:ReturnType; }; for(const sheet of state.sheet==='__all__'?names:[state.sheet])renderSheet(sheet); previous.disabled=state.sheet!=='__all__'&&names.indexOf(state.sheet)===0;next.disabled=names.indexOf(state.sheet)===names.length-1; - + requestAnimationFrame(fit); }; previous.onclick=()=>{selector.value=names[Math.max(0,names.indexOf(state.sheet)-1)];draw();viewport.scrollTo(0,0);}; next.onclick=()=>{selector.value=names[Math.min(names.length-1,names.indexOf(state.sheet)+1)];draw();viewport.scrollTo(0,0);}; diff --git a/수량산출/model.ts b/수량산출/model.ts index 2340eba..b5a3c8e 100644 --- a/수량산출/model.ts +++ b/수량산출/model.ts @@ -1,3 +1,4 @@ +import {bearingDimensions} from './bearing-dimensions'; import {straightCopingFormulas} from './coping-formulas'; import type {PierInput} from '../src/core/types'; import {DIM,type PierModel} from '../src/core/geometry'; @@ -16,9 +17,9 @@ export function withTemplateDefaults(stored:QuantityInputs):QuantityInputs{ } export function automaticInputs(input:PierInput,m:PierModel):Mapped[]{ const uniform=(v:(number|undefined)[])=>v.length&&v.every(x=>x!==undefined&&Number.isFinite(x)&&Math.abs(x!-v[0]!)<1e-7)?v[0]!:null; - const footing=m.footings[0],nopiles=m.piles.length===0; + const footing=m.footings[0],nopiles=m.piles.length===0,bearing=bearingDimensions(m); const slope=m.copingL>0&&m.segments.length?((m.segments[m.segments.length-1].top-m.segments[0].top)/m.copingL)*100:null; - const auto:Record={18:[m.copingRefTop,'model.copingRefTop (원본 Zb 의미 검토)'],19:[input.groundEl,'input.groundEl'],20:[input.groundWaterOn===true&&Number.isFinite(input.groundWaterEl)?input.groundWaterEl!:null,'input.groundWaterEl / 지하수 미지정은 직접 확인'],24:[m.copingL,'model.copingL'],25:[m.copingB,'model.copingB'],26:[m.h2,'model.h2 · 하부 테이퍼 높이'],27:[m.h1,'model.h1 · 상부 높이'],28:[slope,'모델 양단 상면 높이차/Lc ×100 · 단차 포함 등가경사'],29:[0,'현재 코핑 모델은 수직 끝단 · 별도 돌출부 없음'],30:[null,'직선 모델: 곡률 없음 · 원본 곡률항의 직선 극한 사용'],31:[uniform(m.columns.map(c=>c.diameter)),'model.columns[].diameter'],32:[footing?.bottom??null,'model.footings[0].bottom'],33:[footing?.length??null,'model.footings[0].length'],34:[footing?.width??null,'model.footings[0].width'],35:[footing?footing.top-footing.bottom:null,'기초 상면-하면'],36:[nopiles?0:uniform(m.piles.map(p=>p.diameter)),'model.piles[].diameter'],37:[nopiles?0:input.pileThicknessMm!=null?input.pileThicknessMm/1000:null,'입력 말뚝두께 mm→m'],38:[nopiles?0:uniform(m.piles.map(p=>p.top-p.bottom)),'말뚝 상면-하면'],39:[m.piles.length,'model.piles.length'],40:[uniform(m.blocks.map(b=>b.plan?.A!==undefined?b.plan.A/1000:undefined)),'받침 plan.A mm→m'],41:[uniform(m.blocks.map(b=>b.plan?.B!==undefined?b.plan.B/1000:undefined)),'받침 plan.B mm→m'],42:[m.blocks.length,'model.blocks.length'],43:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],44:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],45:[m.blocks.length?m.blocks.reduce((s,b)=>s+b.top-b.bottom,0)/m.blocks.length:null,'동일 평면 블럭 높이 합/Nb (원식의 등가 높이)'],46:[uniform(m.blocks.map(b=>b.plan?.T!==undefined?b.plan.T/1000:undefined))??(input.solplate?.T!=null?input.solplate.T/1000:null),'실제 솔플레이트 plan.T 또는 입력 공통 T mm→m']}; + const auto:Record={18:[m.copingRefTop,'model.copingRefTop (원본 Zb 의미 검토)'],19:[input.groundEl,'input.groundEl'],20:[input.groundWaterOn===true&&Number.isFinite(input.groundWaterEl)?input.groundWaterEl!:null,'input.groundWaterEl / 지하수 미지정은 직접 확인'],24:[m.copingL,'model.copingL'],25:[m.copingB,'model.copingB'],26:[m.h2,'model.h2 · 하부 테이퍼 높이'],27:[m.h1,'model.h1 · 상부 높이'],28:[slope,'모델 양단 상면 높이차/Lc ×100 · 단차 포함 등가경사'],29:[0,'현재 코핑 모델은 수직 끝단 · 별도 돌출부 없음'],30:[null,'직선 모델: 곡률 없음 · 원본 곡률항의 직선 극한 사용'],31:[uniform(m.columns.map(c=>c.diameter)),'model.columns[].diameter'],32:[footing?.bottom??null,'model.footings[0].bottom'],33:[footing?.length??null,'model.footings[0].length'],34:[footing?.width??null,'model.footings[0].width'],35:[footing?footing.top-footing.bottom:null,'기초 상면-하면'],36:[nopiles?0:uniform(m.piles.map(p=>p.diameter)),'model.piles[].diameter'],37:[nopiles?0:input.pileThicknessMm!=null?input.pileThicknessMm/1000:null,'입력 말뚝두께 mm→m'],38:[nopiles?0:uniform(m.piles.map(p=>p.top-p.bottom)),'말뚝 상면-하면'],39:[m.piles.length,'model.piles.length'],40:[bearing.length,'3D 받침 본체: (plan.A+margin)/1000−0.2 m'],41:[bearing.width,'3D 받침 본체: (plan.B+margin)/1000−0.2 m'],42:[m.blocks.length,'model.blocks.length'],43:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],44:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],45:[m.blocks.length?m.blocks.reduce((s,b)=>s+b.top-b.bottom,0)/m.blocks.length:null,'동일 평면 블럭 높이 합/Nb (원식의 등가 높이)'],46:[uniform(m.blocks.map(b=>b.plan?.T!==undefined?b.plan.T/1000:undefined))??(input.solplate?.T!=null?input.solplate.T/1000:null),'실제 솔플레이트 plan.T 또는 입력 공통 T mm→m']}; return variables.map(v=>{const a=auto[v.row];return {...v,value:a?.[0]??null,source:a?.[1]??`${INPUTS}!N${v.row} · 모델에 없는 조건`,automatic:a?.[0]!=null||v.row===30,notApplicable:v.row===30};}); } export function geometrySignature(input:PierInput,m:PierModel):string{const {quantityTakeoff,...raw}=input;return JSON.stringify({raw,auto:automaticInputs(input,m),version:templateHash});} diff --git a/수량산출/project-inputs.ts b/수량산출/project-inputs.ts new file mode 100644 index 0000000..211e2e2 --- /dev/null +++ b/수량산출/project-inputs.ts @@ -0,0 +1,43 @@ +import type {PierInput} from '../src/core/types'; +import type {PierModel} from '../src/core/geometry'; +import {calculate,withTemplateDefaults,type QuantityInputs} from './model'; +import {inputFields} from './input-panel'; +import {node} from './dashboard-types'; +export type QuantitySnapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]}; +export function projectRecords(s:QuantitySnapshot){return s.piers.map(p=>{const stored=withTemplateDefaults(p.input.quantityTakeoff??{values:{},reviewed:false});return {pier:p,stored,result:p.model?calculate(p.input,p.model,stored,p.rebarTon):null};});} +export function openProjectInputs(read:()=>QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void,onClose:()=>void,onExport:()=>Promise){ + const dialog=node('dialog');dialog.className='tq-project-dialog';dialog.dataset.projectInputs='true'; + const head=node('header'),title=node('h2','전체 교각 입력 · 수량산출'),close=node('button','닫기 ×');close.type='button';close.onclick=()=>dialog.close();head.append(title,close);dialog.append(head); + const status=node('p'),tools=node('div');tools.className='tq-project-tools'; + const filter=node('label'),missingOnly=node('input');missingOnly.type='checkbox';missingOnly.checked=true;filter.append(missingOnly,document.createTextNode('미입력 항목만'));tools.append(filter); + const recalc=node('button','전체 재계산'),exporter=node('button','전체 교각 Excel 출력');recalc.type=exporter.type='button';tools.append(recalc,exporter);dialog.append(tools,status); + dialog.append(node('p','자동값은 모델에서 가져옵니다. 직접 입력값은 교각별 저장됩니다. 표에 Excel 셀 범위를 붙여넣을 수 있습니다. 받침 형상이 없거나 서로 다른 치수이면 직접 확인이 필요합니다.')); + const scroll=node('div');scroll.className='tq-project-table';dialog.append(scroll); + const draw=()=>{ + const records=projectRecords(read());const rows=records.map(r=>({...r,fields:r.result?inputFields(r.result,r.stored,r.pier):[]})); + const columns=new Map(); + for(const r of rows)for(const f of r.fields)if(f.required&&(!missingOnly.checked||f.missing))columns.set(f.key,f); + // Show real bearing geometry even while reviewing only missing conditions. + for(const r of rows)for(const f of r.fields)if(['r40','r41'].includes(f.key))columns.set(f.key,f); + const keys=[...columns.keys()],table=node('table'),thead=node('thead'),tr=node('tr');tr.append(node('th','교각 / 상태')); + for(const f of columns.values()){const th=node('th',f.name+(f.unit?' · '+f.unit:''));th.title=f.source;tr.append(th);}tr.append(node('th','원본 기준 검토'));thead.append(tr);table.append(thead); + const body=node('tbody');table.append(body);let missing=0; + for(const [ri,r]of rows.entries()){ + const row=node('tr');row.dataset.projectPier=r.pier.input.name; + const label=node('th',r.pier.input.name+' · '+r.pier.input.type);const issues=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:r.result.issues; + label.append(node('small',issues.length?issues.join(' / '):r.result?.missing.length?'누락 '+r.result.missing.length+'개':r.stored.reviewed?'계산 가능':'기준 검토 필요'));row.append(label);missing+=r.result?.missing.length??1; + for(const key of keys){const f=r.fields.find(f=>f.key===key),td=node('td');if(!f){td.textContent='—';row.append(td);continue;} + if(f.kind==='auto'){td.append(node('output',f.value),node('small','자동'));td.title=f.source;} + else{const control=node('input');control.type=f.numeric?'number':'text';if(f.numeric)control.step='any';control.value=f.value;control.dataset.batchKey=key;control.setAttribute('aria-label',r.pier.input.name+' '+f.name);control.classList.toggle('is-missing',f.missing);control.onchange=()=>{const live=projectRecords(read()).find(x=>x.pier.input.name===r.pier.input.name)!;save(r.pier.input.name,{...live.stored,values:{...live.stored.values,[key]:control.value===''?null:f.numeric?Number(control.value):control.value},lastSignature:undefined});control.classList.toggle('is-missing',control.value==='');status.textContent='입력값 저장됨 · 전체 재계산으로 누락 및 적용 범위를 확인하세요.';}; + control.onpaste=event=>{const raw=event.clipboardData?.getData('text/plain');if(!raw||!/[\t\n]/.test(raw))return;event.preventDefault();for(const [dy,line]of raw.trimEnd().split(/\r?\n/).entries())for(const [dx,value]of line.split('\t').entries()){const target=body.rows[ri+dy]?.querySelector(`[data-batch-key="${keys[keys.indexOf(key)+dx]}"]`);if(target){target.value=value.trim();target.dispatchEvent(new Event('change'));}}};td.append(control,node('small',f.kind==='template'?'원본 Excel':'직접 입력')); + }row.append(td); + } + const td=node('td'),review=node('input');review.type='checkbox';review.checked=r.stored.reviewed;review.setAttribute('aria-label',r.pier.input.name+' 원본 기준 확인');review.onchange=()=>{const live=projectRecords(read()).find(x=>x.pier.input.name===r.pier.input.name)!;save(r.pier.input.name,{...live.stored,reviewed:review.checked,lastSignature:undefined});};td.append(review);row.append(td);body.append(row); + } + scroll.replaceChildren(table);status.textContent=`전체 ${rows.length}개 교각 · 미입력 ${missing}개 · 교각별 산출서 합산 방식`; + }; + missingOnly.onchange=draw; + recalc.onclick=()=>{for(const r of projectRecords(read()))if(r.result)save(r.pier.input.name,{...r.stored,lastSignature:r.result.signature});draw();}; + exporter.onclick=async()=>{exporter.disabled=true;try{await onExport();status.textContent='전체 교각 Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.classList.add('tq-project-error');}finally{exporter.disabled=false;}}; + dialog.addEventListener('close',()=>{dialog.remove();onClose();},{once:true});document.body.append(dialog);draw();dialog.showModal(); +} diff --git a/수량산출/review-panel.ts b/수량산출/review-panel.ts index 87071f2..3002b9e 100644 --- a/수량산출/review-panel.ts +++ b/수량산출/review-panel.ts @@ -5,11 +5,13 @@ import {cellKey} from './formula'; import {node,fmt,type TakeoffResult,type Selection} from './dashboard-types'; import {rowStatus} from './results-panel'; import type {QuantityInputs} from './model'; -export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};plan:{selected:PierModel;models:readonly PierModel[]};stale:boolean;miniState:{sheet:string;zoom:number};onCell:(cell:string)=>void}){ +export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};projectRows:{name:string;result:TakeoffResult}[];plan:{selected:PierModel;models:readonly PierModel[]};stale:boolean;miniState:{sheet:string;zoom:number};onCell:(cell:string)=>void}){ const panel=node('aside',undefined,'tq-right-panel'),preview=node('section',undefined,'tq-panel tq-mini-panel');const heading=node('div',undefined,'tq-panel-heading');heading.append(node('h3','산출서 미리보기'));const expand=node('button','⛶');expand.type='button';expand.setAttribute('aria-label','산출서 미리보기 전체화면');heading.append(expand);preview.append(heading); - const mini=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,state:o.miniState,compact:true,onCell:o.onCell});preview.append(mini);panel.append(preview); + const mini=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,projectRows:o.projectRows,state:o.miniState,compact:true,onCell:o.onCell});preview.append(mini);panel.append(preview); const trace=node('section',undefined,'tq-panel tq-formula-panel takeoff-trace');trace.setAttribute('aria-live','polite');panel.append(trace); - expand.onclick=()=>{const dialog=node('dialog',undefined,'tq-preview-dialog');dialog.dataset.quantityPreviewDialog='true';dialog.setAttribute('aria-label','수량산출서 전체화면');const close=node('button','닫기 ×');close.type='button';close.className='tq-dialog-close';close.onclick=()=>dialog.close();dialog.append(close,createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,state:{sheet:'__all__',zoom:1},onCell:o.onCell}));dialog.addEventListener('close',()=>{dialog.remove();expand.focus({preventScroll:true});},{once:true});document.body.append(dialog);dialog.showModal();}; + let dialogTrace:HTMLElement|undefined,hasSelection=false; + const open=()=>{const dialog=node('dialog',undefined,'tq-preview-dialog tq-report-dialog');dialog.dataset.quantityPreviewDialog='true';dialog.setAttribute('aria-label','수량산출서 확대 및 산식 상세');const close=node('button','닫기 ×');close.type='button';close.className='tq-dialog-close';close.onclick=()=>dialog.close();const grid=node('div',undefined,'tq-report-grid'),documentView=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,projectRows:o.projectRows,state:{sheet:'표지및목차',zoom:0},onCell:o.onCell});dialogTrace=trace.cloneNode(true) as HTMLElement;dialogTrace.classList.add('tq-dialog-trace');grid.append(documentView,dialogTrace);dialog.append(close,grid);dialog.addEventListener('close',()=>{dialogTrace=undefined;dialog.remove();expand.focus({preventScroll:true});},{once:true});document.body.append(dialog);dialog.showModal();}; + expand.onclick=open; const select=(s:Selection)=>{ trace.replaceChildren(node('h3','산식 상세'),node('p',s.title,'tq-selected-title')); const block=(label:string,content:HTMLElement)=>{const row=node('div',undefined,'tq-formula-block');row.append(node('b',label),content);trace.append(row);}; @@ -23,7 +25,8 @@ export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs; const source=node('details',undefined,'tq-formula-source');source.append(node('summary','원본식 · 중간 계산값 · 참조 셀'));source.append(node('pre',s.formula)); for(const r of s.rows)if(r.formula!==r.sourceFormula)source.append(node('p',r.cell+' · 직선 모델: 원본 곡률항의 R→∞ 극한 적용'),node('pre','원본: '+r.sourceFormula)); for(const key of deps){const ix=key.lastIndexOf('!'),sheet=key.slice(0,ix),cell=key.slice(ix+1);if(s.rows.some(r=>sheet===DETAIL&&r.cell===cell))continue;let value:string;try{value=String(o.result.book.cell(sheet,cell));}catch{value='미입력';}source.append(node('p',key+' = '+value));}trace.append(source); - mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)})); + if(dialogTrace)dialogTrace.replaceChildren(...Array.from(trace.childNodes,n=>n.cloneNode(true))); + if(hasSelection)mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)}));hasSelection=true; }; - return {panel,select,focusPreview:()=>{preview.tabIndex=-1;preview.focus();}}; + return {panel,select,open,focusPreview:()=>{preview.tabIndex=-1;preview.focus();}}; } diff --git a/수량산출/server.ts b/수량산출/server.ts index f4ff8e3..006598b 100644 --- a/수량산출/server.ts +++ b/수량산출/server.ts @@ -10,9 +10,12 @@ export function quantityPlugin(root:string):Plugin{ const host=req.headers.host??'',remote=req.socket.remoteAddress??''; if(req.method!=='POST'||req.headers['x-pierzainer-action']!=='quantity'||! /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(remote)||req.headers.origin&&req.headers.origin!==`http://${host}`&&req.headers.origin!==`https://${host}`){reply(403,{error:'이 PC의 Pierzainer에서 출력하세요.'});return;} try{let raw='';for await(const part of req){raw+=part;if(raw.length>2000000)throw new Error('출력 요청이 너무 큽니다.');} - const payload=exportEdits(JSON.parse(raw) as ExportRequest),folder=path.join(root,'수량산출/outputs',randomUUID());await mkdir(folder,{recursive:true}); + const request=JSON.parse(raw) as ExportRequest&{piers?:ExportRequest[]}; + const records=request.piers??[request];if(!records.length||records.length>100)throw new Error('출력 교각 수를 확인하세요.'); + for(const key of ['specFooting','specBody','specBlinding','specBearing','specMortar'])if(new Set(records.map(r=>String(r.specs[key]??''))).size>1)throw new Error('교각별 재료 규격이 서로 다릅니다. 원본 집계표의 동일 공종·규격에 합산할 수 없어 규격별 집계 검토가 필요합니다.'); + const payload={projects:records.map(r=>({...exportEdits(r),pier:r.pier})),bridge:records[0].project.bridge},folder=path.join(root,'수량산출/outputs',randomUUID());await mkdir(folder,{recursive:true}); const input=path.join(folder,'edits.json'),output=path.join(folder,'quantity.xlsx');await writeFile(input,JSON.stringify(payload),'utf8'); - await new Promise((resolve,reject)=>{const child=spawn(path.join(root,'pierzainer_ground/.venv/Scripts/python.exe'),['-B','-X','utf8',path.join(root,'수량산출/tools/preserve_template.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(e);});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});}); + await new Promise((resolve,reject)=>{const child=spawn(path.join(root,'pierzainer_ground/.venv/Scripts/python.exe'),['-B','-X','utf8',path.join(root,'수량산출/tools/project_workbook.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(e);});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});}); res.writeHead(200,{'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','Content-Disposition':'attachment; filename="quantity.xlsx"','Cache-Control':'no-store'});res.end(await readFile(output)); }catch(e){reply(422,{error:(e as Error).message});} }; diff --git a/수량산출/tools/project_workbook.py b/수량산출/tools/project_workbook.py new file mode 100644 index 0000000..13a6e66 --- /dev/null +++ b/수량산출/tools/project_workbook.py @@ -0,0 +1,133 @@ +"""Compose source-format per-pier reports and a formula-linked project summary. + +Uses original OOXML parts. No spreadsheet-library roundtrip or source overwrite. +""" +from pathlib import Path +from copy import deepcopy +import json,re,zipfile,sys +from xml.etree import ElementTree as E +from preserve_template import write,ROOT +S='http://schemas.openxmlformats.org/spreadsheetml/2006/main' +R='http://schemas.openxmlformats.org/officeDocument/2006/relationships' +P='http://schemas.openxmlformats.org/package/2006/relationships' +ns={'s':S} +E.register_namespace('',S);E.register_namespace('r',R) +def col(n): + out='' + while n:n,i=divmod(n-1,26);out=chr(65+i)+out + return out +def number(ref): + n=0 + for c in re.match('[A-Z]+',ref)[0]:n=n*26+ord(c)-64 + return n +def xml(n):return E.tostring(n,encoding='utf-8',xml_declaration=True) +def text(c,value): + for n in list(c):c.remove(n) + c.set('t','inlineStr');E.SubElement(E.SubElement(c,'{'+S+'}is'),'{'+S+'}t').text=value +def formula(c,value,cache): + for n in list(c):c.remove(n) + c.attrib.pop('t',None);E.SubElement(c,'{'+S+'}f').text=value;E.SubElement(c,'{'+S+'}v').text=str(cache) +def build(payload_path,output_path): + output=Path(output_path).resolve() + if not output.is_relative_to((ROOT/'outputs').resolve()) or output.exists():raise ValueError('새 결과 경로를 사용하세요.') + payload=json.loads(Path(payload_path).read_text(encoding='utf-8-sig'));projects=payload['projects'] + if not projects or len(projects)>100:raise ValueError('교각 수는 1~100개여야 합니다.') + archives=[];names=[];used=set() + for i,p in enumerate(projects): + name=p['pier'];safe=re.sub(r'[\\/*?:\[\]]','_',name)[:18] + if safe in used:raise ValueError('교각명 중복: '+name) + used.add(safe);names.append(('3.2 변수_'+safe,'3.3 수량산출_'+safe)) + patch=output.parent/f'pier-{i+1}-edits.json';part=output.parent/f'pier-{i+1}.xlsx' + patch.write_text(json.dumps(p,ensure_ascii=False),encoding='utf-8');write(patch,part);archives.append(zipfile.ZipFile(part)) + base=archives[0];parts={i.filename:base.read(i.filename) for i in base.infolist() if not i.filename.startswith('xl/worksheets/') and i.filename!='xl/calcChain.xml'} + source=zipfile.ZipFile(ROOT/'교각수량산출.xlsx');summary=E.fromstring(source.read('xl/worksheets/sheet2.xml')) + # Preserve the source summary's work rows and formatting. Repeat the P1 3-column + # block for each actual pier and sum only those calculated pier cells. + summary_checks=0 + detail_values=[] + for z in archives: + cells=E.fromstring(z.read('xl/worksheets/sheet8.xml')).findall('.//s:c',ns) + detail_values.append({c.get('r'):float(c.find('s:v',ns).text) for c in cells if c.find('s:v',ns) is not None and c.get('t') not in ('s','e')}) + for row in summary.find('s:sheetData',ns): + cells={c.get('r'):c for c in row};rn=int(row.get('r'));samples=[deepcopy(c) for c in row if 14<=number(c.get('r'))<=16] + for c in list(row): + if number(c.get('r'))>=14:row.remove(c) + values=[] + for i,p in enumerate(projects): + for sample in samples: + c=deepcopy(sample);c.set('r',col(number(sample.get('r'))+i*3)+str(rn)) + old=sample.find('s:f',ns) + if old is not None: + target=re.search(r'!\$?([A-Z]+)\$?(\d+)',old.text or '') + if target: + cell=target[1]+target[2];cache=detail_values[i].get(cell) + if cache is None:raise ValueError(f'{p["pier"]} {cell} 미산정') + formula(c,"'"+names[i][1].replace("'","''")+"'!"+cell,cache) + if number(sample.get('r'))==14:values.append(float(cache)) + elif rn in (4,48) and number(sample.get('r'))==14:text(c,p['pier']) + row.append(c) + total=cells.get('K'+str(rn)) + if total is not None and values: + formula(total,'SUM('+','.join(col(14+3*i)+str(rn) for i in range(len(projects)))+')',sum(values));summary_checks+=1 + merges=summary.find('s:mergeCells',ns) + if merges is not None: + originals=[m.get('ref') for m in merges];merges.clear() + for ref in originals: + a,b=ref.split(':') if ':' in ref else (ref,ref) + if number(b)<14:E.SubElement(merges,'{'+S+'}mergeCell',{'ref':ref}) + elif number(a)>=14 and number(b)<=16: + for i in range(len(projects)): + shift=lambda s:col(number(s)+i*3)+re.search(r'\d+',s)[0] + E.SubElement(merges,'{'+S+'}mergeCell',{'ref':shift(a)+':'+shift(b)}) + merges.set('count',str(len(merges))) + end=col(13+3*len(projects));dimension=summary.find('s:dimension',ns) + if dimension is not None:dimension.set('ref','A1:'+end+'85') + cols=summary.find('s:cols',ns) + if cols is not None and len(projects)>6:E.SubElement(cols,'{'+S+'}col',{'min':'32','max':str(13+3*len(projects)),'width':'2.5','customWidth':'1'}) + sheets=[('표지및목차',base.read('xl/worksheets/sheet1.xml'),1),('1. 수량집계',xml(summary),2)] + for i,name in [(3,'3.1 수량산출기준_토공'),(4,'3.1 수량산출기준_구조체 현황'),(5,'3.1 수량산출기준_구조체')]:sheets.append((name,base.read(f'xl/worksheets/sheet{i}.xml'),i)) + for i,z in enumerate(archives): + for oldindex,newname in [(6,names[i][0]),(8,names[i][1])]: + data=z.read(f'xl/worksheets/sheet{oldindex}.xml').decode('utf8') + # Work on formula text only: identifiers/comments and labels stay unchanged. + def refs(m):return m[0].replace('3.2 관련변수',names[i][0]).replace('3.3 수량산출_P1',names[i][1]) + data=re.sub(r']*>.*?',refs,data,flags=re.S) + sheets.append((newname,data.encode('utf8'),oldindex)) + workbook=E.fromstring(base.read('xl/workbook.xml'));sheetlist=workbook.find('s:sheets',ns);sheetlist.clear() + defined=workbook.find('s:definedNames',ns) + if defined is None:defined=E.SubElement(workbook,'{'+S+'}definedNames') + olddefs=list(defined);defined.clear() + rels=E.fromstring(base.read('xl/_rels/workbook.xml.rels')) + for r in list(rels): + if r.get('Type','').endswith(('/worksheet','/calcChain')):rels.remove(r) + types=E.fromstring(base.read('[Content_Types].xml'));ct='http://schemas.openxmlformats.org/package/2006/content-types' + for t in list(types): + if t.get('PartName','').startswith('/xl/worksheets/'):types.remove(t) + for index,(name,data,original) in enumerate(sheets,1): + path=f'xl/worksheets/sheet{index}.xml';parts[path]=data + E.SubElement(sheetlist,'{'+S+'}sheet',{'name':name,'sheetId':str(index),'{'+R+'}id':'projectSheet'+str(index)}) + E.SubElement(rels,'{'+P+'}Relationship',{'Id':'projectSheet'+str(index),'Type':R+'/worksheet','Target':'worksheets/sheet'+str(index)+'.xml'}) + E.SubElement(types,'{'+ct+'}Override',{'PartName':'/'+path,'ContentType':'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'}) + oldrel=f'xl/worksheets/_rels/sheet{original}.xml.rels' + if oldrel in base.namelist():parts[f'xl/worksheets/_rels/sheet{index}.xml.rels']=base.read(oldrel) + for d in olddefs: + if d.get('localSheetId')==str(original-1): + item=deepcopy(d);item.set('localSheetId',str(index-1));item.text="'"+name.replace("'","''")+"'!"+(d.text or '').split('!',1)[-1] + if original==2:item.text=re.sub(r'\$AE\$',f'${end}$',item.text) + defined.append(item) + for v in workbook.findall('s:bookViews/s:workbookView',ns):v.set('activeTab','0');v.set('firstSheet','0') + parts['xl/workbook.xml']=xml(workbook);parts['xl/_rels/workbook.xml.rels']=xml(rels);parts['[Content_Types].xml']=xml(types) + # Cover lists actual reports, not the original P1~P6 or example. + cover=E.fromstring(parts['xl/worksheets/sheet1.xml']) + for c in cover.findall('.//s:c',ns): + if c.get('r')=='A14':text(c,payload.get('bridge','')+' · 전체 '+str(len(projects))+'개 교각') + parts['xl/worksheets/sheet1.xml']=xml(cover) + with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as z: + for path,data in parts.items():z.writestr(path,data) + report={'piers':[p['pier'] for p in projects],'sheets':[s[0] for s in sheets],'summary_rows':summary_checks,'example_removed':True,'summary_policy':'source work rows; SUM of per-pier formula-linked values','original_preserved':True} + output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8') + for z in archives:z.close() + return report +if __name__=='__main__': + try:print(json.dumps(build(sys.argv[1],sys.argv[2]),ensure_ascii=False)) + except Exception as e:print(json.dumps({'error':str(e)},ensure_ascii=False));sys.exit(1) diff --git a/수량산출/tools/verify_project_package.py b/수량산출/tools/verify_project_package.py new file mode 100644 index 0000000..56849d9 --- /dev/null +++ b/수량산출/tools/verify_project_package.py @@ -0,0 +1,33 @@ +"""Read-only verification of emitted per-pier caches and all summary SUM rows.""" +import zipfile,re,json,sys +from pathlib import Path +from xml.etree import ElementTree as E +S={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} +def check(path,expected): + z=zipfile.ZipFile(path);assert z.testzip() is None + wb=E.fromstring(z.read('xl/workbook.xml'));sheets=wb.find('s:sheets',S);names=[s.get('name') for s in sheets] + assert not any('_ex)' in n or '미사용' in n for n in names) + detail=[n for n in names if n.startswith('3.3 수량산출_')];assert len(detail)==expected,(detail,expected) + rels={r.get('Id'):r.get('Target') for r in E.fromstring(z.read('xl/_rels/workbook.xml.rels'))} + allcells={} + for sheet in sheets: + path='xl/'+rels[sheet.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')] + root=E.fromstring(z.read(path));allcells[sheet.get('name')]={c.get('r'):c for c in root.findall('.//s:c',S)} + for f in root.findall('.//s:f',S):assert '_ex)' not in (f.text or '') + summary=allcells['1. 수량집계'];checks=0 + for ref,c in summary.items(): + f=c.find('s:f',S) + if not ref.startswith('K') or f is None or not (f.text or '').startswith('SUM('):continue + values=[] + for cellref in f.text.removeprefix('SUM(').removesuffix(')').split(','): + cell=summary[cellref];rf=cell.find('s:f',S).text + m=re.match(r"'(.+)'!([A-Z]+\d+)",rf);assert m and m[1] in detail,rf + source=allcells[m[1]][m[2]] + a=float(source.find('s:v',S).text);b=float(cell.find('s:v',S).text);assert abs(a-b)<1e-8 + values.append(a) + assert len(values)==expected + assert abs(sum(values)-float(c.find('s:v',S).text))<1e-7 + checks+=1 + assert checks==53,checks + return {'piers':expected,'sheets':len(sheets),'summary_rows_checked':checks,'source_formula_links':checks*expected,'example_removed':True,'cache_differences':0} +if __name__=='__main__':print(json.dumps(check(sys.argv[1],int(sys.argv[2])),ensure_ascii=False)) diff --git a/수량산출/workspace.css b/수량산출/workspace.css index 5648d0b..185e6b2 100644 --- a/수량산출/workspace.css +++ b/수량산출/workspace.css @@ -51,3 +51,7 @@ @media(max-width:700px){.tq-dashboard-grid{display:flex;flex-direction:column;height:auto;min-height:0}.tq-input-panel{height:430px;flex-shrink:0}.tq-results-panel{height:650px;flex-shrink:0}.tq-right-panel{display:grid;grid-template-columns:1fr;grid-template-rows:360px 400px;flex-shrink:0}.tq-flow{gap:2px}.tq-flow button{gap:3px}.tq-flow svg{display:none}.tq-flow em{font-size:8px}.tq-toolbar{gap:6px}.takeoff-actions{width:100%;justify-content:flex-end}.tq-header-progress{display:none}} @media(prefers-reduced-motion:reduce){.tq-quantity-changed{animation:none}} @media print{.takeoff-menu,.takeoff-actions,.tq-flow,.tq-input-panel,.tq-result-controls{display:none!important}.tq-dashboard-grid{display:block}.tq-results-panel,.tq-right-panel,.tq-panel{overflow:visible;height:auto}.tq-table-scroll{overflow:visible}.tq-summary-cards{break-inside:avoid}} +.tq-project-dialog{width:min(96vw,1800px);max-height:92dvh;box-sizing:border-box;border:1px solid #cfdeec;border-radius:14px;padding:20px;background:#f8fbff;color:#17324c;box-shadow:0 20px 80px #1234;overflow:auto} +.tq-project-dialog::backdrop{background:#142a4780}.tq-project-dialog>header,.tq-project-tools{display:flex;align-items:center;gap:12px;justify-content:space-between}.tq-project-dialog h2{font-size:20px;margin:0}.tq-project-dialog p{font-size:13px;white-space:pre-line}.tq-project-tools{justify-content:flex-end;margin:16px 0}.tq-project-tools label{margin-right:auto;display:flex;align-items:center;gap:8px}.tq-project-dialog button{padding:10px 14px;border:1px solid #b8d4ef;border-radius:7px;background:white;color:#175da0}.tq-project-table{max-height:64dvh;overflow:auto;border:1px solid #d4e1ed;background:white}.tq-project-table table{border-collapse:collapse;min-width:100%;font-size:12px}.tq-project-table th,.tq-project-table td{border:1px solid #dde6ef;padding:9px;min-width:150px}.tq-project-table th{background:#edf5fd;text-align:left}.tq-project-table thead th{position:sticky;top:0;z-index:2}.tq-project-table tbody th{position:sticky;left:0;z-index:1;max-width:220px}.tq-project-table small{display:block;margin-top:5px;color:#72839a;max-width:240px;font-weight:400}.tq-project-table input:not([type=checkbox]){width:130px;box-sizing:border-box;padding:9px;border:1px solid #b9d3ed;border-radius:5px;background:white;color:#17324c}.tq-project-table input.is-missing{border-color:#f49d53;background:#fff8ed}.tq-project-table output{display:block;background:#edf6ff;padding:9px;border-radius:5px}.tq-project-error{color:#a94413} +.tq-report-dialog{display:flex;flex-direction:column;overflow:hidden}.tq-report-dialog:not([open]){display:none}.tq-report-grid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;flex:1;min-height:0}.tq-report-grid>.takeoff-excel-preview{display:flex;flex-direction:column;min-width:0;min-height:0}.tq-report-grid .takeoff-excel-toolbar{flex-shrink:0}.tq-report-grid .takeoff-excel-viewport{flex:1;min-height:0;max-height:none;padding:14px}.tq-report-grid .takeoff-excel-paper{margin:auto}.tq-dialog-trace{overflow:auto;min-height:0;padding:16px;background:white;border:1px solid #d5e2ed;border-radius:8px}.takeoff-excel-viewport.is-page-fit{display:flex;align-items:center;justify-content:center}.takeoff-excel-viewport.is-page-fit>.takeoff-excel-paper{flex:none;margin:auto}.tq-report-dialog .tq-dialog-close{flex-shrink:0} +@media(max-width:1000px){.tq-report-grid{grid-template-columns:minmax(0,1fr) 260px}.tq-report-grid .takeoff-excel-toolbar>span{display:none}} diff --git a/수량산출/workspace.ts b/수량산출/workspace.ts index 5dd7df9..3ae5297 100644 --- a/수량산출/workspace.ts +++ b/수량산출/workspace.ts @@ -1,5 +1,6 @@ import {createExcelPreview} from './excel-preview'; import {quantityPlanSvg,planPng} from './plan-preview'; +import {openProjectInputs,projectRecords} from './project-inputs'; import {createInputPanel} from './input-panel'; import {createResultsPanel} from './results-panel'; import {createReviewPanel} from './review-panel'; @@ -12,40 +13,53 @@ import './workspace.css'; type Snapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]}; const blank=():QuantityInputs=>({values:{},reviewed:false}); export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,select:(name:string)=>void,save:(name:string,data:QuantityInputs)=>void){ - let mode='takeoff';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'3.3 수량산출_P1',zoom:.4}; + let mode='takeoff';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'표지및목차',zoom:0}; const state:DashboardState={query:'',view:'member',selected:'W6',closed:new Set(),openInputs:new Set(['basic','ground','material','conditions']),inputScroll:0}; const calculated=new Map>();let changed=new Set(),changedUntil=0,lastPier=''; const legacy=[...host.children] as HTMLElement[],nav=e('nav');nav.className='takeoff-menu';nav.setAttribute('aria-label','재료표 및 수량산출'); const area=e('section');area.className='takeoff-workspace';area.hidden=true; - for(const [id,label]of [['materials','재료표'],['takeoff','교각 수량산출'],['preview','수량산출서 미리보기']]){const b=e('button',label);b.type='button';b.dataset.takeoffMode=id;b.onclick=()=>{mode=id;refresh();};nav.append(b);}host.prepend(nav);host.append(area); + for(const [id,label]of [['materials','재료표'],['takeoff','수량산출']]){const b=e('button',label);b.type='button';b.dataset.takeoffMode=id;b.onclick=()=>{mode=id;refresh();};nav.append(b);}host.prepend(nav);host.append(area); + async function exportProject(){ + const snap=snapshot(),records=projectRecords(snap),problems:string[]=[]; + for(const r of records){const reasons=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:[...r.result.missing,...r.result.issues,...(!r.stored.reviewed?['원본 기준 확인 필요']:[])];if(reasons.length)problems.push(r.pier.input.name+': '+reasons.join(', '));} + if(!records.length||problems.length)throw new Error(problems.length?problems.join('\n'):'출력할 교각이 없습니다.'); + const models=records.flatMap(r=>r.pier.model?[r.pier.model]:[]),image=await planPng(quantityPlanSvg(models[0],models)); + const piers=records.map((r,i)=>({project:snap.project,pier:r.pier.input.name,overrides:r.result!.overrides,specs:r.stored.values,reviewed:r.stored.reviewed,copingMode:r.result!.copingMode,templateHash,...(i===0?{planPng:image}:{})})); + const response=await fetch('/__pierzainer/quantity/export',{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'quantity'},body:JSON.stringify({piers})}); + if(!response.ok){const error=await response.json();throw new Error(error.error??'전체 교각 Excel 출력 실패');} + for(const r of records)save(r.pier.input.name,{...r.stored,lastSignature:r.result!.signature}); + const url=URL.createObjectURL(await response.blob()),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_전체교각_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); + } function refresh(){ document.querySelectorAll('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();}); legacy.forEach(n=>n.hidden=mode!=='materials');area.hidden=mode==='materials';for(const b of nav.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.takeoffMode===mode));if(mode==='materials')return; const snap=snapshot(),pier=snap.piers.find(p=>p.input.name===snap.selected)??snap.piers[0];area.replaceChildren();area.classList.toggle('is-full-preview',mode==='preview'); - const toolbar=e('header',undefined,'takeoff-toolbar tq-toolbar'),selector=e('select');selector.setAttribute('aria-label','수량산출 대상 교각');for(const p of snap.piers){const opt=e('option',p.input.name+' · '+p.input.type);opt.value=p.input.name;opt.selected=p.input.name===pier?.input.name;selector.append(opt);}selector.onchange=()=>select(selector.value);toolbar.append(e('h2','교각 수량산출'),selector);area.append(toolbar); + const toolbar=e('header',undefined,'takeoff-toolbar tq-toolbar'),selector=e('select');selector.setAttribute('aria-label','수량산출 대상 교각');for(const p of snap.piers){const opt=e('option',p.input.name+' · '+p.input.type);opt.value=p.input.name;opt.selected=p.input.name===pier?.input.name;selector.append(opt);}selector.onchange=()=>select(selector.value);toolbar.append(selector);area.append(toolbar); if(!pier?.model){area.append(e('p','형상 생성에서 교각을 먼저 생성하세요.'));return;} - if(lastPier!==pier.input.name){lastPier=pier.input.name;state.selected='W6';state.inputScroll=0;changed=new Set();} + if(lastPier!==pier.input.name){lastPier=pier.input.name;miniState.sheet='표지및목차';state.selected='W6';state.inputScroll=0;changed=new Set();} + const projectRows=projectRecords(snap).flatMap(r=>r.result?[{name:r.pier.input.name,result:r.result}]:[]); const plan={selected:pier.model,models:snap.piers.flatMap(p=>p.model?[p.model]:[])}; const stored=withTemplateDefaults(pier.input.quantityTakeoff??blank()),result=calculate(pier.input,pier.model,stored,pier.rebarTon),stale=stored.lastSignature!==result.signature||pier.dirty; const status=e('p',pier.dirty?'형상 재생성 필요':result.issues.length?'적용 범위 검토 필요':result.missing.length?`누락 ${result.missing.length}개`:stale?'재계산 필요':result.complete?'계산 완료':'원본 기준 확인 필요','takeoff-status');status.dataset.state=!stale&&result.complete?'done':'pending';status.setAttribute('role','status');toolbar.append(status); const persist=(next:QuantityInputs)=>{save(pier.input.name,next);refresh();}; const actions=e('div',undefined,'takeoff-actions'),calc=e('button','↻ 재계산'),exporter=e('button','Excel 출력'),openPreview=e('button',mode==='preview'?'대시보드로 돌아가기':'미리보기 열기');calc.type=exporter.type=openPreview.type='button';calc.setAttribute('aria-label','수량산출 재계산'); calc.onclick=()=>{const before=calculated.get(pier.input.name);changed=new Set(result.results.filter(r=>before?.has(r.cell)&&before.get(r.cell)!==r.value).map(r=>r.cell));changedUntil=Date.now()+2500;calculated.set(pier.input.name,new Map(result.results.map(r=>[r.cell,r.value])));persist({...stored,lastSignature:result.signature});}; - exporter.disabled=stale||!result.complete;exporter.title=result.missing.length?'필수 누락 항목을 입력하고 재계산하세요.':result.issues.length?'원본 적용 범위를 먼저 검토하세요.':stale?'재계산 후 출력할 수 있습니다.':'원본 양식 Excel 출력'; - exporter.onclick=async()=>{exporter.disabled=true;status.textContent='Excel 생성 중…';try{const response=await fetch('/__pierzainer/quantity/export',{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'quantity'},body:JSON.stringify({project:snap.project,pier:pier.input.name,overrides:result.overrides,specs:stored.values,reviewed:stored.reviewed,copingMode:result.copingMode,templateHash,planPng:await planPng(quantityPlanSvg(plan.selected,plan.models))})});if(!response.ok){const error=await response.json();throw new Error(error.error??'Excel 출력 실패');}const url=URL.createObjectURL(await response.blob()),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_${pier.input.name}_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);status.textContent='Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.title=status.textContent;}finally{exporter.disabled=false;}}; - openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};actions.append(calc,exporter,openPreview);toolbar.append(actions); + exporter.disabled=false;exporter.title='모든 교각의 산출서와 합산 수량집계를 출력합니다.'; + exporter.onclick=async()=>{exporter.disabled=true;status.textContent='전체 교각 Excel 생성 중…';try{await exportProject();status.textContent='전체 교각 Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.title=status.textContent;}finally{exporter.disabled=false;}}; + openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};const allInputs=e('button','전체 교각 입력');allInputs.type='button';allInputs.onclick=()=>openProjectInputs(snapshot,save,refresh,exportProject);actions.append(allInputs,calc,exporter,openPreview);toolbar.append(actions); if(mode==='preview'){ const full=e('section',undefined,'tq-full-preview');const detail=e('section',undefined,'takeoff-trace tq-full-trace'); - const paper=createExcelPreview({result,stored,pier:pier.input.name,project:snap.project,plan,state:fullState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(!row)return;detail.replaceChildren(e('h3',row.name),e('pre',String(row.formula)),e('pre',result.book.substitution('3.3 수량산출_P1',String(row.formula))),e('p',String(row.value??'미산정')+' '+row.unit));detail.scrollIntoView({block:'nearest'});}});full.append(paper,detail);area.append(full);return; + const paper=createExcelPreview({result,stored,pier:pier.input.name,project:snap.project,plan,projectRows,state:fullState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(!row)return;detail.replaceChildren(e('h3',row.name),e('pre',String(row.formula)),e('pre',result.book.substitution('3.3 수량산출_P1',String(row.formula))),e('p',String(row.value??'미산정')+' '+row.unit));detail.scrollIntoView({block:'nearest'});}});full.append(paper,detail);area.append(full);return; } const inputs=createInputPanel({result,stored,pier,project:snap.project,state,onChange:persist}); - const review=createReviewPanel({result,stored,pier:pier.input.name,project:snap.project,plan,stale,miniState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(row){state.selected=cell;results.paint();review.select(rawSelection(row,result.book));}}}); + const review=createReviewPanel({result,stored,pier:pier.input.name,project:snap.project,plan,projectRows,stale,miniState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(row){state.selected=cell;results.paint();review.select(rawSelection(row,result.book));}}}); const results=createResultsPanel({result,stale,reviewed:stored.reviewed,state,changed:Date.now()review.select(s),onReview:()=>{}}); const progress=e('span',`필수입력 ${inputs.done}/${inputs.total}`,'tq-header-progress');toolbar.insertBefore(progress,status); const flow=e('div',undefined,'tq-flow');flow.setAttribute('aria-label','수량산출 작업 단계'); for(const [i,title,description,type]of [[1,'입력','교각 정보 및 계산 조건 입력','input'],[2,'검토','입력값 및 누락 항목 확인','review'],[3,'수량 결과','항목별 수량 산출 및 확인','result'],[4,'산출서 미리보기','최종 검토 및 출력','preview']] as [number,string,string,string][]){const b=e('button');b.type='button';b.dataset.workflowStep=String(i);b.append(icon(type));const text=e('span');text.append(e('b',title),e('small',description));if(i===2&&result.missing.length)text.append(e('em',`누락 ${result.missing.length}개`));b.append(text);b.onclick=()=>{if(i===1)inputs.focusMissing();else if(i===2)results.review();else if(i===3)results.focusTable();else review.focusPreview();};flow.append(b);} const grid=e('div',undefined,'tq-dashboard-grid');grid.append(inputs.panel,results.panel,review.panel);area.append(flow,grid); const footer=e('div',undefined,'tq-input-reset');const reset=e('button','직접 입력 초기화');reset.type='button';reset.onclick=()=>persist(blank());footer.append(reset);inputs.panel.append(footer); + openPreview.onclick=()=>review.open(); results.paint(); } return {refresh};