Files
reimburse/native-engine/expense_template.py
T
2026-09-17 14:13:31 +08:00

441 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)