import copy import hashlib import json import math import posixpath import re import zipfile from decimal import Decimal from pathlib import Path from lxml import etree as ET MAIN = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' PACKAGE = 'http://schemas.openxmlformats.org/package/2006/relationships' CONTENT = 'http://schemas.openxmlformats.org/package/2006/content-types' def tag(name): return '{' + MAIN + '}' + name def xml(data): return ET.fromstring(data, ET.XMLParser(resolve_entities=False, no_network=True)) def encoded(document): return ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True) def coordinate(reference): match = re.fullmatch(r'([A-Z]{1,3})([1-9][0-9]{0,3})', reference) if not match: raise ValueError('单元格地址不正确:' + reference) column = 0 for character in match[1]: column = column * 26 + ord(character) - 64 row = int(match[2]) if column > 256 or row > 2000: raise ValueError('模板填充范围限于前 256 列、2000 行') return column, row def column_name(number): result = '' while number: number, remainder = divmod(number - 1, 26) result = chr(65 + remainder) + result return result def cell_values(document, shared): result = {} for cell in document.iter(tag('c')): value = cell.findtext(tag('v'), '') if cell.get('t') == 's': value = shared[int(value)] if value else '' elif cell.get('t') == 'inlineStr': value = ''.join(cell.itertext()) if cell.find(tag('is')) is not None else '' if cell.find(tag('f')) is not None: value = '=' + cell.findtext(tag('f'), '') if value: result[cell.get('r')] = value return result class Template: def __init__(self, path): path = Path(path) if path.suffix.lower() != '.xlsx' or not path.is_file(): raise ValueError('请选择有效的 .xlsx 报销单模板;旧版 .xls 请先另存为 .xlsx') if path.stat().st_size > 20 * 1024 * 1024: raise ValueError('模板不能超过 20 MB') self.digest = hashlib.sha256(path.read_bytes()).hexdigest() try: with zipfile.ZipFile(path) as archive: entries = archive.infolist() if len(entries) > 5000 or sum(entry.file_size for entry in entries) > 100 * 1024 * 1024: raise ValueError('模板内容过大,请使用精简的报销单模板') if len({entry.filename for entry in entries}) != len(entries): raise ValueError('模板包含重复文件项,请重新另存为 .xlsx') self.parts = {entry.filename: archive.read(entry) for entry in entries} if any('vbaproject' in name.lower() for name in self.parts): raise ValueError('不支持带宏的模板,请移除后重试') self.workbook = xml(self.parts['xl/workbook.xml']) self.relations = xml(self.parts['xl/_rels/workbook.xml.rels']) self.types = xml(self.parts['[Content_Types].xml']) shared_xml = xml(self.parts['xl/sharedStrings.xml']) if 'xl/sharedStrings.xml' in self.parts else [] self.shared = [''.join(item.itertext()) for item in shared_xml] self.sheets = list(self.workbook.find(tag('sheets'))) active_paths = set() for sheet in self.sheets: relation = next(item for item in self.relations if item.get('Id') == sheet.get('{' + REL + '}id')) target = relation.get('Target') active_paths.add(target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/' + target)) active_documents = [xml(self.parts[path]) for path in active_paths] + [self.workbook] for document in active_documents: for node in document.iter(): if ET.QName(node).localname in {'f', 'formula', 'formula1', 'formula2', 'definedName'}: if re.search(r'\[[^\]]+\].*!|(?:WEBSERVICE|RTD)\s*\(|\|[^!]+!', node.text or '', re.I): raise ValueError('模板含有正在使用的外部工作簿链接或外部数据公式,请移除后重试') orphan_sheets = {item.get('Target').lstrip('/') if item.get('Target').startswith('/') else posixpath.normpath('xl/' + item.get('Target')) for item in self.relations if item.get('Type', '').endswith('/worksheet')} - active_paths for path in orphan_sheets: self.parts.pop(path, None) self.parts.pop(posixpath.dirname(path) + '/_rels/' + posixpath.basename(path) + '.rels', None) for name in list(self.parts): if name.startswith('xl/externalLinks/'): self.parts.pop(name) external = self.workbook.find(tag('externalReferences')) if external is not None: self.workbook.remove(external) for item in list(self.relations): target = item.get('Target', '') path = target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/' + target) if item.get('Type', '').endswith('/externalLink') or path in orphan_sheets: self.relations.remove(item) for item in list(self.types): path = item.get('PartName', '').lstrip('/') if path.startswith('xl/externalLinks/') or path in orphan_sheets: self.types.remove(item) except (zipfile.BadZipFile, KeyError, ET.XMLSyntaxError, RuntimeError) as error: raise ValueError('无法读取模板;请确认它是未加密、未损坏的 .xlsx 文件') from error def sheet(self, name): sheet = next((item for item in self.sheets if item.get('name') == name), None) if sheet is None: raise ValueError('模板中找不到工作表:' + name) if sheet.get('state', 'visible') != 'visible': raise ValueError('请选择可见工作表作为报销单') relation = next(item for item in self.relations if item.get('Id') == sheet.get('{' + REL + '}id')) target = relation.get('Target') path = target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/' + target) return sheet, path, xml(self.parts[path]) def merged_ranges(document): result = [] for merged in document.iter(tag('mergeCell')): start, end = merged.get('ref').split(':') left, top = coordinate(start) right, bottom = coordinate(end) result.append((start, left, top, right, bottom)) return result def anchor(reference, merges): column, row = coordinate(reference) return next((start for start, left, top, right, bottom in merges if left <= column <= right and top <= row <= bottom), reference) def suggest(document, shared, name): values = cell_values(document, shared) merges = merged_ranges(document) normalized = {reference: re.sub(r'\s+', '', value).lower() for reference, value in values.items()} columns = {} start_row, end_row = 9, 21 headers = { 'purpose': ['支出项目', '报销内容', '费用项目', '用途', '事由', '摘要'], 'amount': ['金额', 'amount'], 'count': ['单据数量', '单据张数', '票据张数', '附件张数', '张数'], 'invoiceType': ['票据', '发票类型', '票据类型'], 'sequence': ['序号'], 'remarks': ['备注', '说明/'], 'category': ['费用类型', '费用类别'], } for row in range(1, 101): candidate = {} for field, labels in headers.items(): found = [reference for reference, value in normalized.items() if coordinate(reference)[1] == row and any(label in value for label in labels)] if len(found) == 1: candidate[field] = re.sub(r'\d+', '', found[0]) if 'purpose' in candidate and 'amount' in candidate: columns = candidate if columns.get('invoiceType') == columns.get('count'): columns.pop('invoiceType', None) start_row = row + 1 end_row = start_row amount_column = columns['amount'] for value in values.values(): match = re.fullmatch(r'=SUM\(\$?' + amount_column + r'\$?' + str(start_row) + r':\$?' + amount_column + r'\$?(\d+)\)', value, re.I) if match: end_row = int(match[1]) break break cells = {} labels = {'recipient': ['收款人'], 'bankName': ['开户行', '开户银行'], 'accountNumber': ['账号', '银行账号', '银行账户'], 'preparer': ['制单人', '报销人'], 'total': ['本次报销金额', '报销合计', '合计金额', '合计']} for field, options in labels.items(): found = [reference for reference, value in normalized.items() if value.rstrip('::') in options] if len(found) == 1: reference = found[0] column, row = coordinate(reference) merged = next((entry for entry in merges if entry[0] == reference), None) if merged: column = merged[3] cells[field] = anchor(column_name(column + 1) + str(row), merges) signature_cells = [] if columns.get('purpose') == 'C' and columns.get('amount') == 'H' and start_row == 9 and end_row == 21: signature_cells = [reference for reference in ['A30', 'D30', 'A31', 'D31', 'A32', 'D32'] if anchor(reference, merges) == reference] return dict(sheetName=name, startRow=start_row, endRow=end_row, columns=columns, cells=cells, signatureCells=','.join(signature_cells), clearCells='') def inspect_template(path): template = Template(path) sheets = [] for sheet in template.sheets: if sheet.get('state', 'visible') != 'visible': continue name = sheet.get('name') _, _, document = template.sheet(name) try: mapping = suggest(document, template.shared, name) except ValueError: mapping = dict(sheetName=name, startRow=9, endRow=21, columns={}, cells={}, signatureCells='', clearCells='') values = cell_values(document, template.shared) rows = {} for reference, value in values.items(): if len(rows) >= 80 and int(re.sub('[A-Z]', '', reference)) not in rows: continue rows.setdefault(int(re.sub('[A-Z]', '', reference)), []).append(reference + ' ' + value[:160]) sheets.append(dict(name=name, mapping=mapping, preview=[f"{row} 行 · " + ' | '.join(entries) for row, entries in sorted(rows.items())])) if not sheets: raise ValueError('模板没有可见工作表') return dict(templateVersion='mapped-xlsx-v1', fingerprint=template.digest, sheets=sheets) def references(value): return [item.upper() for item in re.split(r'[,,;;\s]+', value.strip()) if item] def validate_mapping(template, mapping): if not isinstance(mapping, dict): raise ValueError('请先配置报销单模板的填充位置') _, _, document = template.sheet(mapping.get('sheetName', '')) if document.find(tag('sheetProtection')) is not None: raise ValueError('模板工作表已保护,请先取消保护后导入') start, end = mapping.get('startRow'), mapping.get('endRow') if type(start) is not int or type(end) is not int or not 1 <= start <= end <= 2000 or end - start >= 200: raise ValueError('明细行范围不正确;每页最多支持 200 条明细') columns = {key: value.strip().upper() for key, value in mapping.get('columns', {}).items() if value.strip()} fields = {'sequence', 'invoiceType', 'purpose', 'count', 'amount', 'remarks', 'category'} if set(columns) - fields or not all(field in columns for field in ['purpose', 'amount']): raise ValueError('至少需要指定“用途”和“金额”两列') if len(set(columns.values())) != len(columns): raise ValueError('不同明细字段不能使用同一列') cells = {key: value.strip().upper() for key, value in mapping.get('cells', {}).items() if value.strip()} if set(cells) - {'recipient', 'bankName', 'accountNumber', 'preparer', 'total'}: raise ValueError('模板含有不支持的收款字段') signatures = references(mapping.get('signatureCells', '')) clears = references(mapping.get('clearCells', '')) if len(signatures) > 6: raise ValueError('最多支持 6 个签字岗位单元格') merges = merged_ranges(document) targets = [] for column in columns.values(): coordinate(column + str(start)) targets.extend(column + str(row) for row in range(start, end + 1)) for reference in list(cells.values()) + signatures + clears: _, row = coordinate(reference) if start <= row <= end: raise ValueError('收款、合计、签字或额外清空位置不能放在明细行内:' + reference) targets.append(reference) if len(set(targets)) != len(targets): raise ValueError('填充位置或清空位置重复,请检查配置') for reference in targets: if anchor(reference, merges) != reference: raise ValueError(f'{reference} 位于合并单元格内,请填写左上角 {anchor(reference, merges)}') column, row = coordinate(reference) merged = next((entry for entry in merges if entry[0] == reference), None) if start <= row <= end and merged and merged[4] != row: raise ValueError('明细区不能跨行合并:' + reference) normalized = dict(sheetName=mapping['sheetName'], startRow=start, endRow=end, columns=columns, cells=cells, signatureCells=','.join(signatures), clearCells=','.join(clears)) if 'total' not in cells and not any(cell.find(tag('f')) is not None for cell in document.iter(tag('c'))): raise ValueError('请指定合计单元格,或在模板中设置合计公式') return normalized def mapping_digest(mapping): return hashlib.sha256(json.dumps(mapping, sort_keys=True, ensure_ascii=False).encode()).hexdigest() def validate_template(path, mapping): template = Template(path) normalized = validate_mapping(template, mapping) return dict(templateVersion='mapped-xlsx-v1', fingerprint=template.digest, mapping=normalized, mappingDigest=mapping_digest(normalized)) def set_cell(document, reference, value, formula=False): column, row_number = coordinate(reference) data = document.find(tag('sheetData')) row = next((entry for entry in data if entry.get('r') == str(row_number)), None) if row is None: row = ET.Element(tag('row'), r=str(row_number)) index = next((index for index, entry in enumerate(data) if int(entry.get('r')) > row_number), len(data)) data.insert(index, row) cell = next((entry for entry in row if entry.get('r') == reference), None) if cell is None: cell = ET.Element(tag('c'), r=reference) index = next((index for index, entry in enumerate(row) if coordinate(entry.get('r'))[0] > column), len(row)) row.insert(index, cell) for child in list(cell): cell.remove(child) if formula: cell.attrib.pop('t', None) ET.SubElement(cell, tag('f')).text = value elif 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'), attrib={'{http://www.w3.org/XML/1998/namespace}space': 'preserve'}).text = str(value) def export_custom_expense(state, destination, path, mapping, purposes, signatures, payee, category_purposes): from exports import DEFAULT_SIGNATURES, expense_rows template = Template(path) signatures = signatures or DEFAULT_SIGNATURES mapping = validate_mapping(template, mapping) rows = expense_rows(state['matches'], purposes, category_purposes) if not rows: raise ValueError('请先勾选要导出的已核对材料') sheet, sheet_path, original = template.sheet(mapping['sheetName']) start, end = mapping['startRow'], mapping['endRow'] capacity = end - start + 1 pages = math.ceil(len(rows) / capacity) if pages > 1 and original.find(tag('tableParts')) is not None: raise ValueError('模板明细区使用了 Excel 表对象,不能自动复制分页;请增加明细行容量或先转换为普通区域') parts = template.parts sheets = template.workbook.find(tag('sheets')) original_index = list(sheets).index(sheet) names = template.workbook.find(tag('definedNames')) local_names = [copy.deepcopy(item) for item in names if item.get('localSheetId') == str(original_index)] if names is not None else [] used_names = {item.get('name') for item in sheets} maximum_id = max(int(item.get('sheetId')) for item in sheets) source_rels = posixpath.dirname(sheet_path) + '/_rels/' + posixpath.basename(sheet_path) + '.rels' for page in range(pages): document = copy.deepcopy(original) output_path = sheet_path name = mapping['sheetName'] if page: suffix = f'-续{page + 1}' name = mapping['sheetName'][:31 - len(suffix)] + suffix collision = 1 while name in used_names: name = mapping['sheetName'][:23] + f'-续{page + 1}-{collision}' collision += 1 used_names.add(name) output_path = f'xl/worksheets/receipt-custom-{page + 1}.xml' while output_path in parts: output_path = output_path.replace('.xml', '-copy.xml') relation_id = 'rIdReceiptCustom' + str(page) while any(entry.get('Id') == relation_id for entry in template.relations): relation_id += 'x' new_index = len(sheets) cloned_sheet = ET.SubElement(sheets, tag('sheet'), name=name, sheetId=str(maximum_id + page), attrib={'{' + REL + '}id': relation_id}) if sheet.get('state'): cloned_sheet.set('state', sheet.get('state')) ET.SubElement(template.relations, '{' + PACKAGE + '}Relationship', Id=relation_id, Type=REL + '/worksheet', Target=output_path[3:]) ET.SubElement(template.types, '{' + CONTENT + '}Override', PartName='/' + output_path, ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml') if source_rels in parts: parts['xl/worksheets/_rels/' + posixpath.basename(output_path) + '.rels'] = parts[source_rels] old_ref = "'" + mapping['sheetName'].replace("'", "''") + "'!" new_ref = "'" + name.replace("'", "''") + "'!" for definition in local_names: cloned = copy.deepcopy(definition) cloned.set('localSheetId', str(new_index)) cloned.text = (cloned.text or '').replace(old_ref, new_ref).replace(mapping['sheetName'] + '!', new_ref) names.append(cloned) for formula in document.iter(tag('f')): formula.text = (formula.text or '').replace(old_ref, new_ref).replace(mapping['sheetName'] + '!', new_ref) for row_number in range(start, end + 1): for column in mapping['columns'].values(): set_cell(document, column + str(row_number), '') page_rows = rows[page * capacity:(page + 1) * capacity] for index, row in enumerate(page_rows): fields = dict(sequence=page * capacity + index + 1, invoiceType='/'.join(row['types']), purpose=row['purpose'], category=row['category'], count=row['count'], amount=row['amount'], remarks='') for field, column in mapping['columns'].items(): set_cell(document, column + str(start + index), fields[field]) for field, reference in mapping['cells'].items(): if field == 'total': set_cell(document, reference, f"SUM({mapping['columns']['amount']}{start}:{mapping['columns']['amount']}{end})", formula=True) else: value = str(payee.get(field, '') or '').strip() if field == 'preparer' and not value: value = str(payee.get('recipient', '') or '').strip() set_cell(document, reference, value) for reference in references(mapping['clearCells']): set_cell(document, reference, '') for index, reference in enumerate(references(mapping['signatureCells'])): value = signatures[index].strip() if index < len(signatures) else '' set_cell(document, reference, value + ':' if value else '') parts[output_path] = encoded(document) for name in list(parts): if name.startswith('xl/worksheets/') and name.endswith('.xml'): document = xml(parts[name]) for cell in document.iter(tag('c')): if cell.find(tag('f')) is not None: for value in cell.findall(tag('v')): cell.remove(value) parts[name] = encoded(document) parts.pop('xl/calcChain.xml', None) for relation in list(template.relations): if relation.get('Type', '').endswith('/calcChain'): template.relations.remove(relation) for override in list(template.types): if override.get('PartName') == '/xl/calcChain.xml': template.types.remove(override) calc = template.workbook.find(tag('calcPr')) if calc is None: calc = ET.SubElement(template.workbook, tag('calcPr')) calc.set('calcMode', 'auto') calc.set('fullCalcOnLoad', '1') calc.set('forceFullCalc', '1') for name, document in [('xl/workbook.xml', template.workbook), ('xl/_rels/workbook.xml.rels', template.relations), ('[Content_Types].xml', template.types)]: parts[name] = encoded(document) with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output: for name, data in parts.items(): output.writestr(name, data) return dict(expenseGrouping='category-v1', expenseRowCount=len(rows), templateVersion='mapped-xlsx-v1', templateFingerprint=template.digest, templateMappingDigest=mapping_digest(mapping), expensePageCount=pages)