feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음. - 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용), 프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외 - 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정 - 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결 - 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등) 정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2bebe47eb
commit
fc520ec03d
47 files changed
+2848
-149
No files matched your search
@@ -76,7 +76,7 @@
|
||||
|가설계단 / 워킹타워 / 워킹타워|받침 EL(Zb), 지반고 EL(Zs)|3.3 수량산출_P1!W61 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|
||||
|교량받침(2800kN) / 양방향 H=166 / 양방향 H=166|받침 수(Nb)|3.3 수량산출_P1!W62 · 원식은 ANALYSIS.md|자동 1 / 직접 0|
|
||||
|무수축몰탈 / T=300mm / T=300mm|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb)|3.3 수량산출_P1!W63 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|
||||
|솔플레이트|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb), 솔플레이트 두께(Ts)|3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 4 / 직접 0|
|
||||
|솔플레이트||3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|
||||
|스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W65 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|
||||
|스페이서 / 벽체용 / 벽체용|받침 EL(Zb), 지반고 EL(Zs), 두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc), 저판 저면 EL(Zf)|3.3 수량산출_P1!W66 · 원식은 ANALYSIS.md|자동 10 / 직접 0|
|
||||
|철근가공조립 / 매우복잡 / 매우복잡||3.3 수량산출_P1!W67 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {QuantityInputs} from './model';
|
||||
import type {QuantitySnapshot} from './project-inputs';
|
||||
import {projectRecords} from './project-inputs';
|
||||
|
||||
export type ExportScope='selected'|'all';
|
||||
export function exportRecords(snapshot:QuantitySnapshot,scope:ExportScope){
|
||||
return projectRecords(snapshot).filter(r=>scope==='all'||r.pier.input.name===snapshot.selected);
|
||||
}
|
||||
|
||||
/** Preserve edits made while PNG/Excel generation awaited IO, even across project replacement. */
|
||||
export function finishQuantityExport(start:QuantitySnapshot,scope:ExportScope,live:QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void):string[]{
|
||||
const current=projectRecords(live),changed:string[]=[];
|
||||
for(const before of exportRecords(start,scope)){
|
||||
const after=current.find(r=>r.pier.input.name===before.pier.input.name);
|
||||
if(!after||JSON.stringify(start.project)!==JSON.stringify(live.project)||after.pier.dirty||!after.result||after.pier.model!==before.pier.model||after.result.signature!==before.result?.signature||JSON.stringify(after.stored)!==JSON.stringify(before.stored)){
|
||||
changed.push(before.pier.input.name);continue;
|
||||
}
|
||||
save(after.pier.input.name,{...after.stored,lastSignature:after.result.signature});
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
+8
-1
@@ -2,10 +2,11 @@ import {straightCopingFormulas} from './coping-formulas.ts';
|
||||
import {FormulaBook,cellKey,type Overrides} from './formula.ts';
|
||||
import {template,templateHash} from './template-data.ts';
|
||||
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema.ts';
|
||||
export type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record<string,unknown>;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string};
|
||||
export type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record<string,unknown>;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string;solplateMode?:'model'};
|
||||
export function exportEdits(request:ExportRequest){
|
||||
if(request.templateHash!==templateHash||!request.reviewed)throw new Error('원본 산식 적용 확인 후 다시 계산하세요.');
|
||||
const edits:Record<string,Record<string,{value?:string|number|null;cached?:number|string;formula?:string}>>={},evaluation:Overrides={...request.overrides};
|
||||
if(request.solplateMode!=='model'){delete evaluation[cellKey(DETAIL,'W64')];delete evaluation[cellKey(DETAIL,'K64')];}
|
||||
const value=(sheet:string,cell:string,v:string|number|null)=>{(edits[sheet]??={})[cell]={value:v};evaluation[cellKey(sheet,cell)]=v??0;};
|
||||
for(let r=18;r<=46;r++){
|
||||
const raw=request.overrides[cellKey(INPUTS,'N'+r)];if(raw!==null&&(typeof raw!=='number'||!Number.isFinite(raw)))throw new Error('입력값 누락 N'+r);
|
||||
@@ -13,6 +14,12 @@ export function exportEdits(request:ExportRequest){
|
||||
}
|
||||
for(const v of extraNumbers){const n=request.overrides[cellKey(DETAIL,v.cell)];if(typeof n!=='number'||!Number.isFinite(n)||n<0)throw new Error(v.name+' 입력을 확인하세요.');value(DETAIL,v.cell,n);}
|
||||
for(const v of specifications){const s=request.specs[v.key];if(typeof s!=='string'||!s.trim())throw new Error(v.name+'을 확인하세요.');value(DETAIL,v.cell,s);}
|
||||
if(request.solplateMode==='model'){
|
||||
const ton=request.overrides[cellKey(DETAIL,'W64')];
|
||||
if(typeof ton!=='number'||!Number.isFinite(ton)||ton<0)throw new Error('모델 솔플레이트 중량을 확인하세요.');
|
||||
value(DETAIL,'W64',ton);
|
||||
value(DETAIL,'K64','3D 모델 판별 체적 합계 × 7.85 (앱 재생성 값)');
|
||||
}
|
||||
for(let i=2;i<=6;i++){
|
||||
const sheet='3.3 수량산출_P'+i;
|
||||
for(const [cell,c]of Object.entries(template[sheet]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))value(sheet,cell,null);
|
||||
|
||||
+1
-1
@@ -39,6 +39,6 @@ export class FormulaBook{
|
||||
const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Record<string,()=>number>={SUM:()=>args.reduce((a,b)=>a+b,0),MAX:()=>Math.max(...args),MIN:()=>Math.min(...args),SQRT:()=>Math.sqrt(args[0]),PI:()=>Math.PI,ASIN:()=>Math.asin(args[0]),SIN:()=>Math.sin(args[0])};if(!f[n.name])throw new FormulaError('지원하지 않는 함수 '+n.name);return f[n.name]();
|
||||
}
|
||||
substitution(sheet:string,formula:string):string{return formula.replace(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g,t=>{const p=t.lastIndexOf('!');try{return String(this.cell(p>=0?t.slice(1,p-1).replace(/''/g,"'"):sheet,t.slice(p+1).replace(/\$/g,'')));}catch{return '미입력';}});}
|
||||
references(sheet:string,cell:string):string[]{const result=new Set<string>(),visiting=new Set<string>();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return;
|
||||
references(sheet:string,cell:string):string[]{const result=new Set<string>(),visiting=new Set<string>();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);if(Object.hasOwn(this.overrides,key))return;const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return;
|
||||
for(const t of f.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g)??[]){const p=t.lastIndexOf('!');walk(p>=0?t.slice(1,p-1).replace(/''/g,"'"):s,t.slice(p+1).replace(/\$/g,''));}};walk(sheet,cell);return [...result];}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {describe,it,expect} from 'vitest';
|
||||
import {buildPier} from '../src/core/geometry';
|
||||
import {solplateGeometry} from '../src/core/solplate';
|
||||
import {loadP1} from '../tests/helpers';
|
||||
import {calculate,withTemplateDefaults,DETAIL,type QuantityInputs} from './model';
|
||||
import {exportEdits} from './export';
|
||||
import {template,templateHash} from './template-data';
|
||||
import {cellKey} from './formula';
|
||||
import {exportRecords,finishQuantityExport} from './export-state';
|
||||
import type {QuantitySnapshot} from './project-inputs';
|
||||
|
||||
const base=loadP1();
|
||||
const input={...base,sole:{h3Mm:181,marginMm:100,tByRow:{전열:35,후열:35},slopePct:Object.fromEntries(base.bearings.map(b=>[String(b.no),0]))}};
|
||||
const model=buildPier(input);
|
||||
const stored:QuantityInputs={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
|
||||
const raw=()=>calculate(input,model,stored,1.25);
|
||||
const snapshot=():QuantitySnapshot=>({project:{name:'프로젝트',bridge:'교량'},selected:input.name,piers:[{input:{...input,quantityTakeoff:stored},model,dirty:false,rebarTon:1.25}]});
|
||||
|
||||
describe('W64 모델 기준 수량·Excel',()=>{
|
||||
it('800×740×35mm 10개는 1.62652t이며 Excel에도 같은 숫자를 전달한다',()=>{
|
||||
const result=raw(),row=result.results.find(r=>r.cell==='W64')!;
|
||||
expect(model.blocks).toHaveLength(10);
|
||||
expect(row.value).toBeCloseTo(1.62652,10);
|
||||
expect(row.sourceFormula).toBe(template[DETAIL].W64.value);
|
||||
expect(row.formula).toContain('3D 모델');
|
||||
const payload=exportEdits({project:{name:'',bridge:''},pier:input.name,overrides:result.overrides,specs:withTemplateDefaults(stored).values,reviewed:true,copingMode:result.copingMode,solplateMode:result.solplateMode,templateHash});
|
||||
expect(payload.edits[DETAIL].W64.value).toBeCloseTo(row.value!,12);
|
||||
expect(payload.edits[DETAIL].K64.value).toContain('3D 모델');
|
||||
expect(template[DETAIL].W64.value).toBe('=(BG28+0.1)*(BG29+0.1)*BG34*BG30*7.85');
|
||||
});
|
||||
it('구형 요청은 모델 값을 원식의 캐시로 섞지 않고 기존 계산을 유지한다',()=>{
|
||||
const result=raw();
|
||||
const payload=exportEdits({project:{name:'',bridge:''},pier:input.name,overrides:result.overrides,specs:withTemplateDefaults(stored).values,reviewed:true,copingMode:result.copingMode,templateHash});
|
||||
expect(payload.edits[DETAIL].W64.cached).toBeCloseTo(1.23088,10);
|
||||
expect(payload.edits[DETAIL].W64.value).toBeUndefined();
|
||||
});
|
||||
it('받침별 크기·경사·두께가 달라도 개별 모델 체적으로 합산한다',()=>{
|
||||
const next={...input,sole:{...input.sole,plates:{[String(base.bearings[0].no)]:{aMm:900,bMm:800}},slopePct:{[String(base.bearings[0].no)]:2.1}}};
|
||||
const m=buildPier(next),r=calculate(next,m,{...stored,values:{...stored.values,r40:999,r41:999,r46:999}},1.25);
|
||||
const actual=m.blocks.reduce((sum,b)=>sum+solplateGeometry(b,b.plan).volume*7.85,0);
|
||||
expect(r.results.find(x=>x.cell==='W64')!.value).toBeCloseTo(actual,12);
|
||||
expect(r.results.find(x=>x.cell==='W64')!.dependencies).toEqual([cellKey(DETAIL,'W64')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Excel 비동기 생성 후 입력 보존·범위',()=>{
|
||||
it('선택 출력은 해당 교각만, 전체 출력은 미생성 교각도 검사 대상으로 반환한다',()=>{
|
||||
const s=snapshot();s.piers.push({...s.piers[0],input:{...input,name:'P2'},model:null});
|
||||
expect(exportRecords(s,'selected').map(r=>r.pier.input.name)).toEqual([input.name]);
|
||||
expect(exportRecords(s,'all').map(r=>r.pier.input.name)).toEqual([input.name,'P2']);
|
||||
});
|
||||
it('생성 중 수정한 입력·검토 체크를 이전 스냅샷으로 저장하지 않는다',()=>{
|
||||
const before=snapshot(),live=snapshot(),saved:unknown[]=[];
|
||||
live.piers[0].input={...live.piers[0].input,quantityTakeoff:{values:{...stored.values,r21:6},reviewed:false}};
|
||||
expect(finishQuantityExport(before,'all',live,(...args)=>saved.push(args))).toEqual([input.name]);
|
||||
expect(saved).toEqual([]);
|
||||
expect(live.piers[0].input.quantityTakeoff).toMatchObject({values:{r21:6},reviewed:false});
|
||||
});
|
||||
it('변경이 없으면 현재 입력에 계산 서명만 기록한다',()=>{
|
||||
const s=snapshot(),saved:{name:string;data:QuantityInputs}[]=[];
|
||||
expect(finishQuantityExport(s,'selected',s,(name,data)=>saved.push({name,data}))).toEqual([]);
|
||||
expect(saved[0].data).toMatchObject({values:stored.values,reviewed:true,lastSignature:raw().signature});
|
||||
});
|
||||
it('다른 프로젝트로 바뀌거나 모델이 재생성되면 이전 출력의 서명을 저장하지 않는다',()=>{
|
||||
const before=snapshot(),live=snapshot();live.project={...live.project,name:'다른 프로젝트'};
|
||||
expect(finishQuantityExport(before,'all',live,()=>{throw new Error('과거 입력 저장');})).toEqual([input.name]);
|
||||
live.project=before.project;live.piers[0].model=buildPier(input);
|
||||
expect(finishQuantityExport(before,'all',live,()=>{throw new Error('과거 입력 저장');})).toEqual([input.name]);
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -2,6 +2,7 @@ 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';
|
||||
import {solplateGeometry} from '../src/core/solplate';
|
||||
import {variables,quantityRows,template,templateHash} from './template-data';
|
||||
import {FormulaBook,cellKey,type Overrides} from './formula';
|
||||
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
|
||||
@@ -34,9 +35,13 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
|
||||
stored=withTemplateDefaults(stored);
|
||||
const mapped=automaticInputs(input,m),overrides=overridesFor(mapped,stored),copingMode='straight' as const,formulas=straightCopingFormulas,book=new FormulaBook(overrides,template,formulas);
|
||||
if(rebarTon!=null&&Number.isFinite(rebarTon))overrides[cellKey(DETAIL,'W67')]=rebarTon;
|
||||
const solplateMode='model' as const;
|
||||
const solplateTon=m.blocks.reduce((sum,b)=>sum+solplateGeometry(b,b.plan).volume*7.85,0);
|
||||
overrides[cellKey(DETAIL,'W64')]=solplateTon;
|
||||
overrides[cellKey(DETAIL,'K64')]='3D 모델 판별 체적 합계 × 7.85 (앱 재생성 값)';
|
||||
const results=quantityRows.map(row=>{const dependencies=book.references(DETAIL,row.cell),missing=dependencies.filter(k=>Object.hasOwn(overrides,k)&&overrides[k]===null);let value:number|null=null,error='';
|
||||
try{if(!missing.length)value=Number(book.cell(DETAIL,row.cell));}catch(e){error=(e as Error).message;}
|
||||
return {...row,value,error,missing,dependencies,formula:formulas[cellKey(DETAIL,row.cell)]??template[DETAIL][row.cell]?.value,sourceFormula:template[DETAIL][row.cell]?.value};});
|
||||
return {...row,value,error,missing,dependencies,description:row.cell==='W64'?'3D 모델 판별 체적 합계 × 7.85':row.description,formula:row.cell==='W64'?'3D 모델 솔플레이트 체적 합계 × 7.85 t/m³':formulas[cellKey(DETAIL,row.cell)]??template[DETAIL][row.cell]?.value,sourceFormula:template[DETAIL][row.cell]?.value};});
|
||||
const needed=new Set(results.flatMap(r=>r.dependencies));
|
||||
const missing=mapped.filter(v=>!v.automatic&&needed.has(cellKey(INPUTS,'N'+v.key.slice(1)))&&overrides[cellKey(INPUTS,'N'+v.key.slice(1))]===null).map(v=>v.name);
|
||||
for(const v of extraNumbers)if(overrides[cellKey(DETAIL,v.cell)]===null)missing.push(v.name);
|
||||
@@ -45,6 +50,7 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
|
||||
|
||||
for(const [key,v]of Object.entries(overrides))if(typeof v==='number'&&(/!N(21|22|23|24|25|26|27|29|30|31|33|34|35|36|37|38|39|40|41|42|43|44|45|46)$/.test(key)||key.startsWith(DETAIL+'!'))&&v<0)issues.push(`${key}: 음수 입력을 확인하세요.`);
|
||||
const warnings=['원본 W51의 실제 기둥 거푸집식은 설명 Hcol과 다릅니다(Zb-H1-H2-Zs+Zf). 원식을 유지합니다.','원본 W50 코핑 거푸집식의 계수와 W63 몰탈 0.05m는 표시 설명/규격과 다릅니다.','현재 코핑은 직선/단차 형상: a=0, R 미적용이며 W50/W58의 원본 원호항에 직선 극한(R→∞)을 적용합니다. s는 단차를 포함한 양단 등가경사입니다.','원본 고정 기준: 터파기 여유 각 1m, 버림 두께 0.1m·치수여유 0.2m, 몰탈 두께 0.05m, 솔플레이트 여유 0.1m, 강재비중 7.85.'];
|
||||
return {mapped,overrides,book,results,copingMode,formulas,missing,issues,warnings,complete:!missing.length&&!issues.length&&!results.some(r=>r.value===null||r.error)&&stored.reviewed,signature:geometrySignature(input,m)+'|rebar:'+String(rebarTon??'manual')} ;
|
||||
warnings.push('W64 솔플레이트는 생성 모델의 판별 체적 합계×7.85t/m³를 사용합니다. 원본 Excel 식은 비교 근거로 보존하며 출력 W64는 모델 계산값입니다.');
|
||||
return {mapped,overrides,book,results,copingMode,solplateMode,formulas,missing,issues,warnings,complete:!missing.length&&!issues.length&&!results.some(r=>r.value===null||r.error)&&stored.reviewed,signature:geometrySignature(input,m)+'|rebar:'+String(rebarTon??'manual')+'|solplate-model:'+solplateTon} ;
|
||||
}
|
||||
export function aggregate(results:ReturnType<typeof calculate>[]){return quantityRows.map((r,i)=>({...r,value:results.every(x=>x.complete)?results.reduce((s,x)=>s+x.results[i].value!,0):null}));}
|
||||
@@ -3,8 +3,13 @@ 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 type QuantitySnapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null;rebarIssues?:string[]}[]};
|
||||
export function projectRecords(s:QuantitySnapshot){return s.piers.map(p=>{
|
||||
const stored=withTemplateDefaults(p.input.quantityTakeoff??{values:{},reviewed:false});
|
||||
const calculated=p.model?calculate(p.input,p.model,stored,p.rebarTon):null;
|
||||
const result=calculated?{...calculated,issues:[...calculated.issues,...(p.rebarIssues??[]).map(m=>'철근재료표 일부 계산: '+m)],complete:calculated.complete&&!p.rebarIssues?.length}:null;
|
||||
return {pier:p,stored,result};
|
||||
});}
|
||||
export function openProjectInputs(read:()=>QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void,onClose:()=>void,onExport:()=>Promise<void>){
|
||||
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);
|
||||
|
||||
@@ -23,7 +23,7 @@ export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;
|
||||
block('계산식',node('pre',s.substitution));
|
||||
const answer=node('div',undefined,'tq-formula-answer');answer.append(node('b','결과'),node('strong',fmt(s.value)+' '+s.unit));const status=rowStatus(s.rows,o.stale,!!o.result.issues.length,o.stored.reviewed);answer.append(node('span',status.text,'tq-status-tag '+status.kind));trace.append(answer);
|
||||
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 r of s.rows)if(r.formula!==r.sourceFormula)source.append(node('p',r.cell==='W64'?'W64 · 생성 모델의 판별 체적 합계 사용. Excel 입력을 바꾸면 앱에서 형상을 재생성해 다시 출력하세요.':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);
|
||||
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;
|
||||
|
||||
+30
-18
@@ -1,34 +1,41 @@
|
||||
import {createExcelPreview} from './excel-preview';
|
||||
import {quantityPlanSvg,planPng} from './plan-preview';
|
||||
import {openProjectInputs,projectRecords} from './project-inputs';
|
||||
import {openProjectInputs,projectRecords,type QuantitySnapshot} from './project-inputs';
|
||||
import {createInputPanel} from './input-panel';
|
||||
import {createResultsPanel} from './results-panel';
|
||||
import {createReviewPanel} from './review-panel';
|
||||
import {node as e,icon,rawSelection,type DashboardState,type Selection} from './dashboard-types';
|
||||
import type {PierInput} from '../src/core/types';
|
||||
import type {PierModel} from '../src/core/geometry';
|
||||
import {calculate,withTemplateDefaults,type QuantityInputs} from './model';
|
||||
import type {QuantityInputs} from './model';
|
||||
import {templateHash} from './template-data';
|
||||
import {exportRecords,finishQuantityExport,type ExportScope} from './export-state';
|
||||
import './workspace.css';
|
||||
type Snapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
|
||||
type Snapshot=QuantitySnapshot;
|
||||
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:'표지및목차',zoom:0};
|
||||
let mode='materials';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'표지및목차',zoom:0};
|
||||
let exportBusy=false;
|
||||
const state:DashboardState={query:'',view:'member',selected:'W6',closed:new Set(),openInputs:new Set(['basic','ground','material','conditions']),inputScroll:0};
|
||||
const calculated=new Map<string,Map<string,number|null>>();let changed=new Set<string>(),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','수량산출']]){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(', '));}
|
||||
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(scope:ExportScope='all'){
|
||||
if(exportBusy)throw new Error('Excel을 생성 중입니다. 완료 후 다시 출력하세요.');
|
||||
exportBusy=true;
|
||||
try{
|
||||
const snap=snapshot(),records=exportRecords(snap,scope),problems:string[]=[];
|
||||
for(const r of records){const reasons=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:[...r.result.missing,...r.result.issues,...(!r.stored.reviewed?['원본 기준 확인 필요']:[]),...(r.stored.lastSignature!==r.result.signature?['재계산 필요']:[])];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 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,solplateMode:r.result!.solplateMode,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);
|
||||
const blob=await response.blob();
|
||||
const changed=finishQuantityExport(snap,scope,snapshot(),save);
|
||||
const target=scope==='all'?'전체교각':records[0].pier.input.name;
|
||||
const url=URL.createObjectURL(blob),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_${target}_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
if(changed.length)throw new Error(`Excel은 생성 시작 시점 기준으로 저장했습니다. 생성 중 변경된 ${changed.join(', ')} 입력은 보존했습니다. 재계산 후 다시 출력하세요.`);
|
||||
}finally{exportBusy=false;}
|
||||
}
|
||||
function refresh(){
|
||||
document.querySelectorAll<HTMLDialogElement>('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();});
|
||||
@@ -37,16 +44,21 @@ export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,se
|
||||
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;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 records=projectRecords(snap);
|
||||
const projectRows=records.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 record=records.find(r=>r.pier===pier)!;
|
||||
const stored=record.stored,result=record.result!,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=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);
|
||||
exporter.textContent='선택 교각 Excel';exporter.disabled=exportBusy;exporter.title='선택한 교각만 출력합니다.';exporter.dataset.takeoffExport='selected';
|
||||
const allExporter=e('button','전체 교각 Excel');allExporter.type='button';allExporter.disabled=exportBusy;allExporter.dataset.takeoffExport='all';
|
||||
const notice=(message:string)=>{const current=area.querySelector<HTMLElement>('[role="status"]')??status;current.textContent=message;current.title=message;};
|
||||
const runExport=async(scope:ExportScope)=>{exporter.disabled=allExporter.disabled=true;notice((scope==='all'?'전체 교각':'선택 교각')+' Excel 생성 중…');try{await exportProject(scope);notice('Excel 출력 완료');}catch(error){notice((error as Error).message);}finally{host.querySelectorAll<HTMLButtonElement>('[data-takeoff-export]').forEach(b=>{b.disabled=exportBusy;});}};
|
||||
exporter.onclick=()=>runExport('selected');allExporter.onclick=()=>runExport('all');
|
||||
openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};const allInputs=e('button','전체 교각 입력');allInputs.type='button';allInputs.onclick=()=>openProjectInputs(snapshot,save,refresh,()=>exportProject('all'));actions.append(allInputs,calc,exporter,allExporter,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,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;
|
||||
|
||||
Reference in new issue
Block a user