134 lines
8.0 KiB
Python
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)
|