spreadsheets.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. """应用内的XLSX结果导出与只读预览;只写显式数值,不执行上传文件中的公式。"""
  2. import csv
  3. from io import StringIO
  4. import json
  5. import math
  6. from pathlib import PurePosixPath
  7. import posixpath
  8. import re
  9. from xml.etree import ElementTree as ET
  10. from zipfile import BadZipFile, ZIP_DEFLATED, ZipFile
  11. NS='http://schemas.openxmlformats.org/spreadsheetml/2006/main'
  12. REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships'
  13. PKG='http://schemas.openxmlformats.org/package/2006/relationships'
  14. ET.register_namespace('',NS)
  15. def _plain(value):
  16. if isinstance(value, dict) and 'components_kw' in value:
  17. return '+'.join(f'{v:g}' for v in value['components_kw'])+f" kW(合计{value['total_kw']:g} kW)"
  18. return json.dumps(value,ensure_ascii=False) if isinstance(value,(list,dict)) else value
  19. def result_tables(result):
  20. payload=result.get('platform_payload') or {}
  21. judgment=payload.get('judgment') or {}
  22. assessment=result.get('assessment') or {}
  23. names=[f['fan_name'] for f in assessment.get('fan_pair',[])]
  24. names=names if len(names)==2 else ['设备1','设备2']
  25. rows=[[result.get('result_title') or '智能体研判结果'],['矿井',result.get('mine_name','')],['研判对象',result.get('check_object','')],
  26. ['研判时间',payload.get('checkedAt','')],['结果编码',judgment.get('resultCode','')],['综合结论',judgment.get('result','')],[],
  27. ['判识项目','实际值','单位','正常上限','分项结论']]
  28. if assessment.get('mode') == 'sandbox_exploration':
  29. # 只导出 Agent 的实际交付,不再填充旧七阶段的固定业务行或替它生成计算结果。
  30. agent_result = assessment['agent_result']
  31. rows = [*rows[:7], ['数据范围', agent_result.get('data_detail', '')], [], ['来源', '物理页', '字段路径', '原文']]
  32. rows.extend([[item['source'], item.get('page'), item.get('pointer', ''), item.get('excerpt', '')]
  33. for item in assessment.get('evidence', [])])
  34. details = [['字段', 'Agent 交付值']]
  35. details.extend([[key, _plain(value)] for key, value in agent_result.get('details', {}).items()])
  36. return [{'name': '研判结果', 'rows': rows, 'headers': [10], 'widths': [32, 46, 36, 20, 24]},
  37. {'name': 'Agent分析明细', 'rows': details, 'headers': [1], 'widths': [38, 100]}]
  38. labels={'pass':'通过','fail':'不通过','insufficient':'未形成有效对照'}
  39. decision=assessment.get('decision',{})
  40. if assessment:
  41. rows.append(['两台风机基础参数一致性','','','',labels.get(decision.get('items',{}).get('parameters'),'')])
  42. for event in assessment.get('switch_comparison',{}).get('events',[]):
  43. rows.extend([['停机至启动间隔',event['metrics'].get('switch_interval_minutes'),'分钟',event['timing']['limit_minutes'],labels.get(event['checks'].get('switch_interval'),'')],
  44. ['切换前后稳定风量差',event['metrics'].get('flow_deviation_percent'),'%',event['limits']['flow_deviation_percent'],labels.get(event['checks'].get('flow_change'),'')]])
  45. basic=assessment.get('basic_comparison',{}).get('rows',[])
  46. headers=[8]
  47. if basic:
  48. rows.extend([[],['基础参数',*(names or ['设备1','设备2']),'比较结果']])
  49. headers.append(len(rows))
  50. units={'rated_speed_rpm':'r/min','impeller_diameter_m':'m','rated_flow_range_m3_min':'m³/min','rated_pressure_range_pa':'Pa'}
  51. rows.extend([[r['label']+(f"({units[r['field']]})" if r['field'] in units else ''),
  52. *[_plain(v) for v in r['values']],'一致' if r['status']=='match' else '不一致'] for r in basic])
  53. tables=[{'name':'研判结果','rows':rows,'headers':headers,'widths':[32,46,36,20,24]}]
  54. curves=assessment.get('performance_comparison',{}).get('results',[])
  55. if curves:
  56. rows=[['频率(Hz)','风量(m³/min)',f'{names[0]}静压(Pa)',f'{names[1]}静压(Pa)',
  57. f'{names[0]}轴功率(kW)',f'{names[1]}轴功率(kW)',f'{names[0]}静压效率(%)',f'{names[1]}静压效率(%)']]
  58. for curve in curves:
  59. 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']])
  60. tables.append({'name':'性能曲线比较','rows':rows,'headers':[1],'widths':[15,23,23,23,23,23,25,25]})
  61. return tables
  62. def _column(index):
  63. text=''
  64. while index:
  65. index,remainder=divmod(index-1,26)
  66. text=chr(65+remainder)+text
  67. return text
  68. def _xml(element):
  69. return ET.tostring(element,encoding='utf-8',xml_declaration=True)
  70. def write_workbook(path, tables):
  71. """服务运行时生成标准OOXML;预览直接读取这个下载文件。"""
  72. content=ET.Element('Types',xmlns='http://schemas.openxmlformats.org/package/2006/content-types')
  73. for extension,kind in [('rels','application/vnd.openxmlformats-package.relationships+xml'),('xml','application/xml')]:
  74. ET.SubElement(content,'Default',Extension=extension,ContentType=kind)
  75. for part,kind in [('workbook','sheet.main'),('styles','styles')]:
  76. ET.SubElement(content,'Override',PartName=f'/xl/{part}.xml',ContentType=f'application/vnd.openxmlformats-officedocument.spreadsheetml.{kind}+xml')
  77. workbook=ET.Element(f'{{{NS}}}workbook')
  78. sheets=ET.SubElement(workbook,f'{{{NS}}}sheets')
  79. relationships=ET.Element('Relationships',xmlns=PKG)
  80. styles='''<?xml version="1.0" encoding="UTF-8"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="3"><font><sz val="11"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="11"/><name val="Microsoft YaHei"/></font><font><b/><sz val="16"/><color rgb="FF173E65"/><name val="Microsoft YaHei"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF173E65"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="5"><xf fontId="0" fillId="0" borderId="0" xfId="0"/><xf fontId="1" fillId="2" borderId="0" xfId="0" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf><xf fontId="2" fillId="0" borderId="0" xfId="0"/><xf fontId="0" fillId="0" borderId="0" numFmtId="2" xfId="0" applyNumberFormat="1"/><xf fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>'''
  81. style_root=ET.fromstring(styles)
  82. for xf in style_root.findall(f'{{{NS}}}cellXfs/{{{NS}}}xf'):
  83. alignment=xf.find(f'{{{NS}}}alignment')
  84. if alignment is None:
  85. alignment=ET.SubElement(xf,f'{{{NS}}}alignment')
  86. alignment.set('vertical','center')
  87. xf.set('applyAlignment','1')
  88. styles=_xml(style_root)
  89. with ZipFile(path,'w',ZIP_DEFLATED) as archive:
  90. archive.writestr('_rels/.rels',f'<Relationships xmlns="{PKG}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>')
  91. archive.writestr('xl/styles.xml',styles)
  92. for number,table in enumerate(tables,1):
  93. ET.SubElement(sheets,f'{{{NS}}}sheet',name=table['name'],sheetId=str(number),attrib={f'{{{REL}}}id':f'rId{number}'})
  94. ET.SubElement(relationships,'Relationship',Id=f'rId{number}',Type=f'{REL}/worksheet',Target=f'worksheets/sheet{number}.xml')
  95. ET.SubElement(content,'Override',PartName=f'/xl/worksheets/sheet{number}.xml',ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
  96. sheet=ET.Element(f'{{{NS}}}worksheet')
  97. views=ET.SubElement(sheet,f'{{{NS}}}sheetViews')
  98. view=ET.SubElement(views,f'{{{NS}}}sheetView',workbookViewId='0',showGridLines='0')
  99. ET.SubElement(view,f'{{{NS}}}pane',ySplit='1',topLeftCell='A2',activePane='bottomLeft',state='frozen')
  100. cols=ET.SubElement(sheet,f'{{{NS}}}cols')
  101. for i,width in enumerate(table['widths'],1):
  102. ET.SubElement(cols,f'{{{NS}}}col',min=str(i),max=str(i),width=str(width),customWidth='1')
  103. body=ET.SubElement(sheet,f'{{{NS}}}sheetData')
  104. for r,values in enumerate(table['rows'],1):
  105. row=ET.SubElement(body,f'{{{NS}}}row',r=str(r),ht='54' if r==6 and number==1 else '30',customHeight='1')
  106. for c,value in enumerate(values,1):
  107. if value is None:
  108. continue
  109. numeric=isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(value)
  110. 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'
  111. cell=ET.SubElement(row,f'{{{NS}}}c',r=f'{_column(c)}{r}',s=style,t='n' if numeric else 'inlineStr')
  112. if numeric:
  113. ET.SubElement(cell,f'{{{NS}}}v').text=str(value)
  114. else:
  115. text=ET.SubElement(ET.SubElement(cell,f'{{{NS}}}is'),f'{{{NS}}}t',attrib={'{http://www.w3.org/XML/1998/namespace}space':'preserve'})
  116. text.text=re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',str(value))[:32767]
  117. if number==1:
  118. merges=ET.SubElement(sheet,f'{{{NS}}}mergeCells',count='2')
  119. ET.SubElement(merges,f'{{{NS}}}mergeCell',ref='A1:E1')
  120. ET.SubElement(merges,f'{{{NS}}}mergeCell',ref='B6:E6')
  121. archive.writestr(f'xl/worksheets/sheet{number}.xml',_xml(sheet))
  122. ET.SubElement(relationships,'Relationship',Id='rIdStyles',Type=f'{REL}/styles',Target='styles.xml')
  123. archive.writestr('xl/workbook.xml',_xml(workbook))
  124. archive.writestr('xl/_rels/workbook.xml.rels',_xml(relationships))
  125. archive.writestr('[Content_Types].xml',_xml(content))
  126. def preview_workbook(path, *, max_rows=300, max_cols=60):
  127. if path.suffix.lower()=='.csv':
  128. text=path.read_bytes()
  129. if len(text)>10*1024*1024:
  130. raise ValueError('CSV预览最大10MB')
  131. try:
  132. value=text.decode('utf-8-sig')
  133. except UnicodeDecodeError:
  134. value=text.decode('gb18030')
  135. rows,total_rows,truncated=[],0,False
  136. try:
  137. for row in csv.reader(StringIO(value)):
  138. total_rows+=1
  139. if total_rows<=max_rows:
  140. rows.append(row[:max_cols])
  141. truncated=truncated or total_rows>max_rows or len(row)>max_cols
  142. except csv.Error as exc:
  143. raise ValueError('CSV内容格式异常,或单个字段过长') from exc
  144. return {'sheets':[{'name':path.stem,'rows':rows,'total_rows':total_rows,'truncated':truncated}]}
  145. try:
  146. with ZipFile(path) as archive:
  147. entries=archive.infolist()
  148. 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:
  149. raise ValueError('工作簿展开内容过大或格式异常')
  150. strings=[]
  151. styles=[]
  152. if 'xl/styles.xml' in archive.namelist():
  153. style_root=ET.fromstring(archive.read('xl/styles.xml'))
  154. fonts=style_root.findall(f'{{{NS}}}fonts/{{{NS}}}font')
  155. fills=style_root.findall(f'{{{NS}}}fills/{{{NS}}}fill')
  156. custom_formats={int(item.attrib['numFmtId']):item.attrib['formatCode'] for item in style_root.findall(f'{{{NS}}}numFmts/{{{NS}}}numFmt')}
  157. 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'}
  158. for xf in style_root.findall(f'{{{NS}}}cellXfs/{{{NS}}}xf'):
  159. style={}
  160. font_id,fill_id=int(xf.attrib.get('fontId','0')),int(xf.attrib.get('fillId','0'))
  161. if font_id<len(fonts):
  162. font=fonts[font_id]
  163. bold=font.find(f'{{{NS}}}b')
  164. if bold is not None and bold.attrib.get('val','1')!='0':
  165. style['bold']=True
  166. size=font.find(f'{{{NS}}}sz')
  167. if size is not None:
  168. style['font_size']=float(size.attrib['val'])
  169. color=font.find(f'{{{NS}}}color')
  170. if color is not None and re.fullmatch(r'(?:[0-9A-Fa-f]{2})?[0-9A-Fa-f]{6}',color.attrib.get('rgb','')):
  171. style['color']='#'+color.attrib['rgb'][-6:]
  172. if fill_id<len(fills):
  173. fill=fills[fill_id].find(f'{{{NS}}}patternFill')
  174. color=fill.find(f'{{{NS}}}fgColor') if fill is not None and fill.attrib.get('patternType')=='solid' else None
  175. if color is not None and re.fullmatch(r'(?:[0-9A-Fa-f]{2})?[0-9A-Fa-f]{6}',color.attrib.get('rgb','')):
  176. style['background']='#'+color.attrib['rgb'][-6:]
  177. fmt_id=int(xf.attrib.get('numFmtId','0'))
  178. style['number_format']=custom_formats.get(fmt_id,builtin_formats.get(fmt_id,'General'))
  179. styles.append(style)
  180. if 'xl/sharedStrings.xml' in archive.namelist():
  181. shared=ET.fromstring(archive.read('xl/sharedStrings.xml'))
  182. strings=[''.join(si.itertext()) for si in shared.findall(f'{{{NS}}}si')]
  183. relationships=ET.fromstring(archive.read('xl/_rels/workbook.xml.rels'))
  184. targets={r.attrib['Id']:r.attrib['Target'] for r in relationships if r.attrib.get('TargetMode')!='External'}
  185. workbook=ET.fromstring(archive.read('xl/workbook.xml'))
  186. sheets=[]
  187. for record in workbook.findall(f'{{{NS}}}sheets/{{{NS}}}sheet')[:30]:
  188. target=targets[record.attrib[f'{{{REL}}}id']]
  189. filename=posixpath.normpath(target.lstrip('/') if target.startswith('/') else 'xl/'+target)
  190. if not filename.startswith('xl/'):
  191. raise ValueError('工作簿引用的工作表路径异常')
  192. sheet=ET.fromstring(archive.read(filename))
  193. rows=[]
  194. cell_styles={}
  195. total_rows=0
  196. truncated=False
  197. for row in sheet.findall(f'{{{NS}}}sheetData/{{{NS}}}row'):
  198. number=int(row.attrib.get('r',total_rows+1))
  199. total_rows=max(total_rows,number)
  200. if number>max_rows:
  201. truncated=True
  202. continue
  203. while len(rows)<number:
  204. rows.append([])
  205. values=rows[number-1]
  206. for cell in row.findall(f'{{{NS}}}c'):
  207. column=re.match(r'([A-Z]+)',cell.attrib.get('r','A1'))
  208. index=0
  209. for ch in column[1]:
  210. index=index*26+ord(ch)-64
  211. if index>max_cols:
  212. truncated=True
  213. continue
  214. while len(values)<index:
  215. values.append(None)
  216. cell_styles[f'{number-1}:{index-1}']=int(cell.attrib.get('s','0'))
  217. kind=cell.attrib.get('t')
  218. value=cell.findtext(f'{{{NS}}}v')
  219. if kind=='inlineStr':
  220. value=''.join(cell.find(f'{{{NS}}}is').itertext())
  221. elif kind=='s':
  222. value=strings[int(value)]
  223. elif kind=='b':
  224. value=value=='1'
  225. elif kind not in {'str','e','d'} and value is not None:
  226. value=float(value)
  227. if not math.isfinite(value):
  228. raise ValueError('工作簿包含非有限数值')
  229. if value.is_integer():
  230. value=int(value)
  231. if value is None and cell.find(f'{{{NS}}}f') is not None:
  232. value='='+cell.findtext(f'{{{NS}}}f')
  233. values[index-1]=value
  234. merges=[]
  235. for merge in sheet.findall(f'{{{NS}}}mergeCells/{{{NS}}}mergeCell'):
  236. match=re.fullmatch(r'([A-Z]+)([1-9][0-9]*):([A-Z]+)([1-9][0-9]*)',merge.attrib.get('ref',''))
  237. if not match:
  238. continue
  239. left,top,right,bottom=match.groups()
  240. col=lambda text:sum((ord(c)-64)*26**i for i,c in enumerate(reversed(text)))-1
  241. start_col,end_col=col(left),col(right)
  242. start_row,end_row=int(top)-1,int(bottom)-1
  243. if start_row<max_rows and start_col<max_cols and end_col>=start_col and end_row>=start_row:
  244. 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})
  245. sheets.append({'name':record.attrib['name'],'rows':rows,'total_rows':total_rows,'truncated':truncated,'merges':merges,'cell_styles':cell_styles})
  246. return {'sheets':sheets,'styles':styles,'total_sheets':len(workbook.findall(f'{{{NS}}}sheets/{{{NS}}}sheet'))}
  247. except (BadZipFile, ET.ParseError,KeyError,IndexError,TypeError,AttributeError,RuntimeError) as exc:
  248. raise ValueError('不是可预览的XLSX文件,或工作簿内容已损坏') from exc