import copy import io import math import posixpath import zipfile from decimal import Decimal from pathlib import Path from xml.etree import ElementTree as XML from PIL import Image from domain import material_date, text DEFAULT_SIGNATURES = ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人', '制片人'] def image_for(item): return Image.open(item.get('previewPath') or item['path']).convert('RGB') def export_ppt(state, destination, classified): from pptx import Presentation from pptx.util import Pt from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import PP_ALIGN presentation = Presentation() presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720) def slide(): return presentation.slides.add_slide(presentation.slide_layouts[6]) def picture(page, item, box): image = image_for(item) left, top, width, height = box scale = min(width / image.width, height / image.height) actual_width, actual_height = int(image.width * scale), int(image.height * scale) content = io.BytesIO() image.save(content, format='PNG') content.seek(0) page.shapes.add_picture(content, Pt(left + (width - actual_width) // 2), Pt(top + (height - actual_height) // 2), Pt(actual_width), Pt(actual_height)) def payment_page(payments): page = slide() height = 660 if 'approvedRecords' in state else 680 if len(payments) == 1: picture(page, payments[0], (300, 20, 680, height)) else: width = (1200 - 24 * (len(payments) - 1)) // len(payments) for index, item in enumerate(payments): picture(page, item, (40 + index * (width + 24), 20, width, height)) def label(page, box, value, size, title=False): shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box)) shape.fill.solid() shape.fill.fore_color.rgb = RGBColor(245, 248, 247) if title else RGBColor(255, 255, 255) shape.line.color.rgb = RGBColor(216, 226, 222) if title else RGBColor(170, 188, 182) shape.line.width = Pt(1 if title else 1.5) shape.text = value for paragraph in shape.text_frame.paragraphs: paragraph.alignment = PP_ALIGN.CENTER for run in paragraph.runs: run.font.name = 'PingFang SC' run.font.size = Pt(size) run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126) def photos(count): for offset in range(0, count, 4): slots = min(4, count - offset) page = slide() label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True) for index in range(slots): box = (250, 105, 780, 555) if slots == 1 else (55 + index * 610, 105, 560, 555) if slots == 2 else (55 + index % 2 * 610, 105 + index // 2 * 285, 560, 255) label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16) def requires_photo(match): return match['category'].strip() not in ['交通', '住宿'] def simple_batch(batch): if not batch: return for match in batch: picture(slide(), match['invoices'][0], (40, 25, 1200, 670)) payment_page([match['payments'][0] for match in batch]) photos(sum(len(match['payments']) for match in batch if requires_photo(match))) if 'approvedRecords' in state: import contextlib import tempfile import pypdfium2 as pdfium from pptx.enum.text import MSO_AUTO_SIZE records = state['approvedRecords'] if not records or len(records) > 100: raise ValueError('每批请选择 1 至 100 笔已批准申请') with tempfile.TemporaryDirectory(prefix='approved-ppt-pages-', dir=Path(destination).parent) as directory: page_count = 0 for record in records: summary = slide() label(summary, (55, 28, 1170, 64), '已批准报销 · #' + record['id'], 26, True) details = [record['projectName'] + ' / ' + record['team'], record['name'] + ' · ' + record['type'], '提交日期:' + record['date'] + ' 报销金额:¥' + record['amountText']] for index, detail in enumerate(details): label(summary, (55, 120 + index * 70, 1170, 60), detail, 22) note = record.get('note', '').strip() for offset in range(0, len(note), 240): note_page = summary if offset == 0 else slide() label(note_page, (55, 360 if offset == 0 else 100, 1170, 300), ('备注:' if offset == 0 else '备注(续):') + note[offset:offset + 240], 18) groups = {} kind_names = {} for material in record['materials']: kind_names[material['kind']] = material.get('kindName', '其他材料') path = Path(material['path']) images = [] try: if path.suffix.lower() == '.pdf': with contextlib.closing(pdfium.PdfDocument(str(path))) as document: if not 0 < len(document) <= 100: raise ValueError('单份 PDF 需为 1–100 页,请拆分后导出') for page_index in range(len(document)): with contextlib.closing(document[page_index]) as pdf_page: scale = min(140 / 72, 2600 / max(pdf_page.get_size())) with contextlib.closing(pdf_page.render(scale=scale)) as bitmap: preview = Path(directory) / f'{page_count}.png' bitmap.to_pil().convert('RGB').save(preview) images.append({'path': str(preview)}) page_count += 1 else: with Image.open(path) as image: image.verify() images.append(material) page_count += 1 except Exception as error: raise ValueError(f"申请 #{record['id']} 的“{material['name']}”无法导出:{error}") from error if page_count > 1000: raise ValueError('材料展开后超过 1,000 页,请减少选择并分批导出') groups.setdefault(material['kind'], []).extend(images) if not groups: label(summary, (55, 660, 1170, 36), '该申请没有上传附件', 16) for item in groups.pop('invoice', []): picture(slide(), item, (40, 25, 1200, 670)) payments = groups.pop('payment', []) for offset in range(0, len(payments), 4): payment_page(payments[offset:offset + 4]) for kind, materials in sorted(groups.items()): for offset in range(0, len(materials), 4): page = slide() title = '实拍照片' if kind in ('receipt', 'photo') else kind_names[kind] label(page, (55, 20, 1170, 50), title, 20, True) batch = materials[offset:offset + 4] for index, item in enumerate(batch): box = (250, 90, 780, 570) if len(batch) == 1 else (55 + index % 2 * 610, 90 + index // 2 * 290, 560, 270) picture(page, item, box) start = list(presentation.slides).index(summary) for page in list(presentation.slides)[start + 1:]: label(page, (40, 696, 1200, 22), f"{record['projectName']} / {record['team']} · {record['name']} · {record['date']} · #{record['id']}", 10, True) for page in presentation.slides: for shape in page.shapes: if shape.has_text_frame: shape.text_frame.word_wrap = True shape.text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE presentation.save(destination) return matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else '')) batch = [] for match in matches: if len(match['invoices']) != 1 or len(match['payments']) != 1: simple_batch(batch) batch = [] for item in sorted(match['invoices'], key=material_date): picture(slide(), item, (40, 25, 1200, 670)) payments = sorted(match['payments'], key=material_date) for index in range(0, len(payments), 4): payment_page(payments[index:index + 4]) if requires_photo(match): photos(len(payments)) else: batch.append(match) if len(batch) == 2: simple_batch(batch) batch = [] simple_batch(batch) presentation.save(destination) def export_travel(state, destination): import mimetypes mimetypes.knownfiles = [] mimetypes.init() from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter invoices = [item for match in state['matches'] for item in match['invoices'] if item['ocr']['travel']['type']] if not invoices: raise ValueError('已核对材料中没有识别到火车票或航班行程') workbook = Workbook() sheet = workbook.active sheet.title = '火车及航班行程' sheet.append(['序号', '交通类型', '姓名', '出发地', '目的地', '出发时间', '车次/航班号', '金额', '发票文件']) for cell in sheet[1]: cell.font = Font(bold=True, color='FFFFFF') cell.fill = PatternFill('solid', fgColor='006633') cell.alignment = Alignment(horizontal='center') for index, item in enumerate(invoices, 1): travel = item['ocr']['travel'] values = [index, '火车' if travel['type'] == 'train' else '航班', travel['travelerName'], travel['departure'], travel['destination'], travel['departureTime'], travel['transportNumber'], next(iter(item['ocr']['amounts']), ''), item['name']] sheet.append(values) for cell in sheet[sheet.max_row]: if isinstance(cell.value, str): cell.data_type = 's' for index, width in enumerate([8, 12, 16, 18, 18, 22, 18, 14, 38], 1): sheet.column_dimensions[get_column_letter(index)].width = width sheet.freeze_panes = 'A2' workbook.save(destination) def export_expense(state, destination, template, purposes, signatures): from lxml import etree as ET namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' relationships = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' package = 'http://schemas.openxmlformats.org/package/2006/relationships' content_namespace = 'http://schemas.openxmlformats.org/package/2006/content-types' tag = lambda name: '{' + namespace + '}' + name with zipfile.ZipFile(template) as source: parts = {name: source.read(name) for name in source.namelist()} workbook = ET.fromstring(parts['xl/workbook.xml']) relations = ET.fromstring(parts['xl/_rels/workbook.xml.rels']) content_types = ET.fromstring(parts['[Content_Types].xml']) sheets = workbook.find(tag('sheets')) original = next(sheet for sheet in sheets if sheet.get('name') == '1个人报销单') original_index = list(sheets).index(original) relation = next(item for item in relations if item.get('Id') == original.get('{' + relationships + '}id')) sheet_path = posixpath.normpath(posixpath.join('xl', relation.get('Target'))) if not relation.get('Target').startswith('/') else relation.get('Target').lstrip('/') sheet_bytes = parts[sheet_path] names = workbook.find(tag('definedNames')) retained_names = [copy.deepcopy(item) for item in names if item.get('localSheetId') == str(original_index)] if names is not None else [] if names is not None: names.clear() sheets.clear() signatures = signatures or DEFAULT_SIGNATURES page_count = math.ceil(len(state['matches']) / 13) def set_cell(document, reference, value): cell = document.find('.//' + tag('c') + '[@r="' + reference + '"]') if cell is None: row_number = ''.join(character for character in reference if character.isdigit()) row = document.find('.//' + tag('row') + '[@r="' + row_number + '"]') if row is None: raise ValueError('报销模板缺少明细行:' + row_number) cell = ET.SubElement(row, tag('c'), r=reference) for child in list(cell): cell.remove(child) if isinstance(value, (int, float, Decimal)): cell.set('t', 'n') ET.SubElement(cell, tag('v')).text = str(value) else: cell.set('t', 'inlineStr') inline = ET.SubElement(cell, tag('is')) ET.SubElement(inline, tag('t')).text = str(value) for page in range(page_count): document = ET.fromstring(sheet_bytes) for row_number in range(9, 22): for column in ['B', 'C', 'G', 'H', 'I']: set_cell(document, column + str(row_number), '') set_cell(document, 'A' + str(row_number), row_number - 8) for index, match in enumerate(state['matches'][page * 13:(page + 1) * 13]): invoice_type = '普票' for invoice in match['invoices']: content = text(invoice) if '专用发票' in content: invoice_type = '专票' break if 'invoice' in content.lower(): invoice_type = 'Invoice' break if '押金' in content and '收据' in content: invoice_type = '押金收据' break total = sum(max((abs(Decimal(value.replace(',', ''))) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices']) for column, value in [('B', invoice_type), ('C', purposes.get(match['id'], '').strip()), ('G', len(match['invoices'])), ('H', total)]: set_cell(document, column + str(index + 9), value) for index, cell in enumerate(['A30', 'D30', 'A31', 'D31', 'A32', 'D32']): value = signatures[index].strip() if index < len(signatures) else '' set_cell(document, cell, value + ':' if value else '') name = '个人报销单' if page_count == 1 else f'个人报销单-{page + 1}' output_path = f'xl/worksheets/native-expense-{page + 1}.xml' relation_id = f'rIdNativeExpense{page + 1}' ET.SubElement(sheets, tag('sheet'), name=name, sheetId=str(page + 1), attrib={'{' + relationships + '}id': relation_id}) ET.SubElement(relations, '{' + package + '}Relationship', Id=relation_id, Type=relationships + '/worksheet', Target=output_path[3:]) ET.SubElement(content_types, '{' + content_namespace + '}Override', PartName='/' + output_path, ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml') parts[output_path] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True) rel_path = posixpath.dirname(sheet_path) + '/_rels/' + posixpath.basename(sheet_path) + '.rels' if rel_path in parts: parts['xl/worksheets/_rels/' + posixpath.basename(output_path) + '.rels'] = parts[rel_path] for definition in retained_names: cloned = copy.deepcopy(definition) cloned.set('localSheetId', str(page)) cloned.text = (cloned.text or '').replace("'1个人报销单'!", "'" + name + "'!").replace('1个人报销单!', "'" + name + "'!") names.append(cloned) calc = workbook.find(tag('calcPr')) if calc is None: calc = ET.SubElement(workbook, tag('calcPr')) calc.set('fullCalcOnLoad', '1') calc.set('forceFullCalc', '1') for key, document in [('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relations), ('[Content_Types].xml', content_types)]: parts[key] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True) with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output: for name, content in parts.items(): output.writestr(name, content)