"""应用内的XLSX结果导出与只读预览;只写显式数值,不执行上传文件中的公式。"""
import csv
from io import StringIO
import json
import math
from pathlib import PurePosixPath
import posixpath
import re
from xml.etree import ElementTree as ET
from zipfile import BadZipFile, ZIP_DEFLATED, ZipFile
NS='http://schemas.openxmlformats.org/spreadsheetml/2006/main'
REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships'
PKG='http://schemas.openxmlformats.org/package/2006/relationships'
ET.register_namespace('',NS)
def _plain(value):
if isinstance(value, dict) and 'components_kw' in value:
return '+'.join(f'{v:g}' for v in value['components_kw'])+f" kW(合计{value['total_kw']:g} kW)"
return json.dumps(value,ensure_ascii=False) if isinstance(value,(list,dict)) else value
def result_tables(result):
payload=result.get('platform_payload') or {}
judgment=payload.get('judgment') or {}
assessment=result.get('assessment') or {}
names=[f['fan_name'] for f in assessment.get('fan_pair',[])]
names=names if len(names)==2 else ['设备1','设备2']
rows=[[result.get('result_title') or '智能体研判结果'],['矿井',result.get('mine_name','')],['研判对象',result.get('check_object','')],
['研判时间',payload.get('checkedAt','')],['结果编码',judgment.get('resultCode','')],['综合结论',judgment.get('result','')],[],
['判识项目','实际值','单位','正常上限','分项结论']]
if assessment.get('mode') == 'sandbox_exploration':
# 只导出 Agent 的实际交付,不再填充旧七阶段的固定业务行或替它生成计算结果。
agent_result = assessment['agent_result']
rows = [*rows[:7], ['数据范围', agent_result.get('data_detail', '')], [], ['来源', '物理页', '字段路径', '原文']]
rows.extend([[item['source'], item.get('page'), item.get('pointer', ''), item.get('excerpt', '')]
for item in assessment.get('evidence', [])])
details = [['字段', 'Agent 交付值']]
details.extend([[key, _plain(value)] for key, value in agent_result.get('details', {}).items()])
return [{'name': '研判结果', 'rows': rows, 'headers': [10], 'widths': [32, 46, 36, 20, 24]},
{'name': 'Agent分析明细', 'rows': details, 'headers': [1], 'widths': [38, 100]}]
labels={'pass':'通过','fail':'不通过','insufficient':'未形成有效对照'}
decision=assessment.get('decision',{})
if assessment:
rows.append(['两台风机基础参数一致性','','','',labels.get(decision.get('items',{}).get('parameters'),'')])
for event in assessment.get('switch_comparison',{}).get('events',[]):
rows.extend([['停机至启动间隔',event['metrics'].get('switch_interval_minutes'),'分钟',event['timing']['limit_minutes'],labels.get(event['checks'].get('switch_interval'),'')],
['切换前后稳定风量差',event['metrics'].get('flow_deviation_percent'),'%',event['limits']['flow_deviation_percent'],labels.get(event['checks'].get('flow_change'),'')]])
basic=assessment.get('basic_comparison',{}).get('rows',[])
headers=[8]
if basic:
rows.extend([[],['基础参数',*(names or ['设备1','设备2']),'比较结果']])
headers.append(len(rows))
units={'rated_speed_rpm':'r/min','impeller_diameter_m':'m','rated_flow_range_m3_min':'m³/min','rated_pressure_range_pa':'Pa'}
rows.extend([[r['label']+(f"({units[r['field']]})" if r['field'] in units else ''),
*[_plain(v) for v in r['values']],'一致' if r['status']=='match' else '不一致'] for r in basic])
tables=[{'name':'研判结果','rows':rows,'headers':headers,'widths':[32,46,36,20,24]}]
curves=assessment.get('performance_comparison',{}).get('results',[])
if curves:
rows=[['频率(Hz)','风量(m³/min)',f'{names[0]}静压(Pa)',f'{names[1]}静压(Pa)',
f'{names[0]}轴功率(kW)',f'{names[1]}轴功率(kW)',f'{names[0]}静压效率(%)',f'{names[1]}静压效率(%)']]
for curve in curves:
rows.extend([[curve['frequency_hz'],r['flow_m3_min'],r['fan_1'][0],r['fan_2'][0],r['fan_1'][1],r['fan_2'][1],r['fan_1'][2],r['fan_2'][2]] for r in curve['curve_samples']])
tables.append({'name':'性能曲线比较','rows':rows,'headers':[1],'widths':[15,23,23,23,23,23,25,25]})
return tables
def _column(index):
text=''
while index:
index,remainder=divmod(index-1,26)
text=chr(65+remainder)+text
return text
def _xml(element):
return ET.tostring(element,encoding='utf-8',xml_declaration=True)
def write_workbook(path, tables):
"""服务运行时生成标准OOXML;预览直接读取这个下载文件。"""
content=ET.Element('Types',xmlns='http://schemas.openxmlformats.org/package/2006/content-types')
for extension,kind in [('rels','application/vnd.openxmlformats-package.relationships+xml'),('xml','application/xml')]:
ET.SubElement(content,'Default',Extension=extension,ContentType=kind)
for part,kind in [('workbook','sheet.main'),('styles','styles')]:
ET.SubElement(content,'Override',PartName=f'/xl/{part}.xml',ContentType=f'application/vnd.openxmlformats-officedocument.spreadsheetml.{kind}+xml')
workbook=ET.Element(f'{{{NS}}}workbook')
sheets=ET.SubElement(workbook,f'{{{NS}}}sheets')
relationships=ET.Element('Relationships',xmlns=PKG)
styles=''''''
style_root=ET.fromstring(styles)
for xf in style_root.findall(f'{{{NS}}}cellXfs/{{{NS}}}xf'):
alignment=xf.find(f'{{{NS}}}alignment')
if alignment is None:
alignment=ET.SubElement(xf,f'{{{NS}}}alignment')
alignment.set('vertical','center')
xf.set('applyAlignment','1')
styles=_xml(style_root)
with ZipFile(path,'w',ZIP_DEFLATED) as archive:
archive.writestr('_rels/.rels',f'')
archive.writestr('xl/styles.xml',styles)
for number,table in enumerate(tables,1):
ET.SubElement(sheets,f'{{{NS}}}sheet',name=table['name'],sheetId=str(number),attrib={f'{{{REL}}}id':f'rId{number}'})
ET.SubElement(relationships,'Relationship',Id=f'rId{number}',Type=f'{REL}/worksheet',Target=f'worksheets/sheet{number}.xml')
ET.SubElement(content,'Override',PartName=f'/xl/worksheets/sheet{number}.xml',ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
sheet=ET.Element(f'{{{NS}}}worksheet')
views=ET.SubElement(sheet,f'{{{NS}}}sheetViews')
view=ET.SubElement(views,f'{{{NS}}}sheetView',workbookViewId='0',showGridLines='0')
ET.SubElement(view,f'{{{NS}}}pane',ySplit='1',topLeftCell='A2',activePane='bottomLeft',state='frozen')
cols=ET.SubElement(sheet,f'{{{NS}}}cols')
for i,width in enumerate(table['widths'],1):
ET.SubElement(cols,f'{{{NS}}}col',min=str(i),max=str(i),width=str(width),customWidth='1')
body=ET.SubElement(sheet,f'{{{NS}}}sheetData')
for r,values in enumerate(table['rows'],1):
row=ET.SubElement(body,f'{{{NS}}}row',r=str(r),ht='54' if r==6 and number==1 else '30',customHeight='1')
for c,value in enumerate(values,1):
if value is None:
continue
numeric=isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(value)
style='1' if r in table.get('headers',[]) else '2' if r==1 and number==1 else '3' if isinstance(value,float) and numeric else '0' if numeric else '4'
cell=ET.SubElement(row,f'{{{NS}}}c',r=f'{_column(c)}{r}',s=style,t='n' if numeric else 'inlineStr')
if numeric:
ET.SubElement(cell,f'{{{NS}}}v').text=str(value)
else:
text=ET.SubElement(ET.SubElement(cell,f'{{{NS}}}is'),f'{{{NS}}}t',attrib={'{http://www.w3.org/XML/1998/namespace}space':'preserve'})
text.text=re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',str(value))[:32767]
if number==1:
merges=ET.SubElement(sheet,f'{{{NS}}}mergeCells',count='2')
ET.SubElement(merges,f'{{{NS}}}mergeCell',ref='A1:E1')
ET.SubElement(merges,f'{{{NS}}}mergeCell',ref='B6:E6')
archive.writestr(f'xl/worksheets/sheet{number}.xml',_xml(sheet))
ET.SubElement(relationships,'Relationship',Id='rIdStyles',Type=f'{REL}/styles',Target='styles.xml')
archive.writestr('xl/workbook.xml',_xml(workbook))
archive.writestr('xl/_rels/workbook.xml.rels',_xml(relationships))
archive.writestr('[Content_Types].xml',_xml(content))
def preview_workbook(path, *, max_rows=300, max_cols=60):
if path.suffix.lower()=='.csv':
text=path.read_bytes()
if len(text)>10*1024*1024:
raise ValueError('CSV预览最大10MB')
try:
value=text.decode('utf-8-sig')
except UnicodeDecodeError:
value=text.decode('gb18030')
rows,total_rows,truncated=[],0,False
try:
for row in csv.reader(StringIO(value)):
total_rows+=1
if total_rows<=max_rows:
rows.append(row[:max_cols])
truncated=truncated or total_rows>max_rows or len(row)>max_cols
except csv.Error as exc:
raise ValueError('CSV内容格式异常,或单个字段过长') from exc
return {'sheets':[{'name':path.stem,'rows':rows,'total_rows':total_rows,'truncated':truncated}]}
try:
with ZipFile(path) as archive:
entries=archive.infolist()
if len(entries)>10000 or len({e.filename for e in entries})!=len(entries) or sum(e.file_size for e in entries)>100*1024*1024:
raise ValueError('工作簿展开内容过大或格式异常')
strings=[]
styles=[]
if 'xl/styles.xml' in archive.namelist():
style_root=ET.fromstring(archive.read('xl/styles.xml'))
fonts=style_root.findall(f'{{{NS}}}fonts/{{{NS}}}font')
fills=style_root.findall(f'{{{NS}}}fills/{{{NS}}}fill')
custom_formats={int(item.attrib['numFmtId']):item.attrib['formatCode'] for item in style_root.findall(f'{{{NS}}}numFmts/{{{NS}}}numFmt')}
builtin_formats={0:'General',1:'0',2:'0.00',3:'#,##0',4:'#,##0.00',9:'0%',10:'0.00%',14:'yyyy-mm-dd',22:'yyyy-mm-dd hh:mm'}
for xf in style_root.findall(f'{{{NS}}}cellXfs/{{{NS}}}xf'):
style={}
font_id,fill_id=int(xf.attrib.get('fontId','0')),int(xf.attrib.get('fillId','0'))
if font_idmax_rows:
truncated=True
continue
while len(rows)max_cols:
truncated=True
continue
while len(values)=start_col and end_row>=start_row:
merges.append({'start_row':start_row,'start_col':start_col,'row_span':min(end_row+1,max_rows)-start_row,'col_span':min(end_col+1,max_cols)-start_col})
sheets.append({'name':record.attrib['name'],'rows':rows,'total_rows':total_rows,'truncated':truncated,'merges':merges,'cell_styles':cell_styles})
return {'sheets':sheets,'styles':styles,'total_sheets':len(workbook.findall(f'{{{NS}}}sheets/{{{NS}}}sheet'))}
except (BadZipFile, ET.ParseError,KeyError,IndexError,TypeError,AttributeError,RuntimeError) as exc:
raise ValueError('不是可预览的XLSX文件,或工作簿内容已损坏') from exc