"""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)