Files
Pier_v1/수량산출/tools/project_workbook.py
T

134 lines
8.0 KiB
Python

"""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'<f\b[^>]*>.*?</f>',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)