This commit is contained in:
csj
2026-09-17 14:13:31 +08:00
parent ced5db5473
commit 10697d3dfa
18 changed files with 2097 additions and 26 deletions
+23 -1
View File
@@ -40,6 +40,28 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
## 自定义个人报销单模板
在工具栏点击“报销模板”,或点击材料扫描页、个人报销单弹窗中的“选择模板 / 查看、更换”。也可以直接从访达将一个 `.xlsx` 文件拖到模板卡片或模板窗口上方的虚线区域;拖入时高亮,处理中不接受重复导入。不接受文件夹、多个文件或网页链接,失败时原模板保持不变。
模板窗口左侧显示 Excel 原表,右侧显示识别到的工作表、明细范围和收款位置。核对后点击“确认位置,使用此模板”。不再默认展示整屏地址输入框;仅在识别不正确时打开“调整填写位置”。未确认的新模板不会覆盖正在使用的模板,可随时放弃更换。
模板只保存本机副本,重启后仍有效,不修改导入的原始文件。仅影响个人报销单,不影响 PPT、行程表及勾选范围;仍按所选费用类型合并金额、单据数,每类型一行。保留模板样式、合并区域、打印设置、其他工作表和未映射内容;明细超出确认范围时复制报销工作表分页。合计和其他公式在 Excel / WPS 打开后重新计算。
导入旧报销单时,务必将全部历史明细行包含在填充范围内,并指定收款字段及额外需要清空的单元格;未映射的位置会保留模板原文。映射的明细旧值会先清空;收款字段没有本机资料时也会清空,账号以文本填入。公司、项目等固定文字可预先在 Excel 中修改后再导入。
“清空重置”可选择“仅清空本次报销数据(保留模板)”或“报销数据和导入模板一起清空”。两者均不删除原始材料、原始 Excel 和钥匙串中的收款资料。模板独立保存于当前 macOS 用户的应用沙盒,不随项目管理登录账号切换。模板损坏或丢失时会阻止导出并提示重新导入,不会静默改用内置模板。
支持最多 20 MB 的未加密、无宏、无活动外部工作簿链接的 `.xlsx`;旧 `.xls` 需先另存为 `.xlsx`。不同格式需要确认填充位置,受保护的报销工作表不支持填充,包含 Excel 表对象的工作表不支持自动复制分页。内置模板仍保持原先导出方式。
### 导出前预览
在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。应用先按当前模板生成实际 Excel,在应用内使用 macOS Quick Look 展示完整表格(含各工作表),而不是另外绘制一份费用摘要。可返回修改,确认无误后点击“确认并保存”选择位置;保存的字节与预览文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。
预览会刷新受支持公式的缓存(求和、同表引用、基本四则运算、条件判断、今日日期及内置人民币大写公式),保留公式本身。复杂自定义公式不猜测结果:移除旧缓存,明确列出尚未计算的单元格,由 Excel / WPS 打开后重算。预览属于屏幕表格展示,不是分页打印校样;打印设置保留在 Excel 中。
修改所选数据、用途、收款资料或模板后需要重新预览。保存前核对输入及文件指纹,防止保存与预览不一致的内容;返回或关闭预览时清理临时文件。临时报销单仅存于应用本地沙盒,包含完整收款信息,不上传。
已核对列表的“匹配”列会显示简要核对项,点击百分比查看每项实际得分、未得分原因及核对建议。证据评分采用真正的 **100 分制**:单张发票与付款截图的权重为金额 40 分、商户 20 分、日期 10 分、单号 15 分、批次金额唯一性 15 分;满分之和为 100,实际得分之和就是百分比,各项未得分之和就是差额。例如仅金额一致且批次唯一时为 55/100 分,即 55%。日期相差不超过 3 天得 10 分,4–7 天得 5 分;商户达到 35% 相似度后按相似度 × 20 分四舍五入。其他组合规则的满分原本就是 100 分,仍保留;多笔付款固定 95 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。
旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore``version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。
@@ -67,7 +89,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
保留一对一评分、多发票合计、多付款合计、同程多人归组、歧义判断、七类关键词分类、人工多对多、撤销、预览、金额统计和三类 Office 文件导出。
个人报销单只使用打开弹窗时勾选的材料,按费用类型合并明细。例如勾选 6 组交通,只生成一条“交通”,金额和发票数量累计;未勾选材料不计入。弹窗显示合并后的类型、组数、单据数和金额,可按类型填写用途。金额沿用原报销单口径(每张发票 OCR 金额的绝对值最大值),不改为付款金额;混合票据类型会一并列明。按首次出现的类型顺序输出,每页最多 13 个类型,收款信息与模板公式保持不变
个人报销单只使用打开弹窗时勾选的材料,按费用类型合并明细。例如勾选 6 组交通,只生成一条“交通”,金额和发票数量累计;未勾选材料不计入。弹窗显示合并后的类型、组数、单据数和金额,可按类型填写用途。金额沿用原报销单口径(每张发票 OCR 金额的绝对值最大值),不改为付款金额;混合票据类型会一并列明。按首次出现的类型顺序输出,内置模板每页最多 13 个类型,自定义模板按确认的明细范围分页
保存个人报销单前还会核对引擎返回的分类合并版本和明细行数;旧引擎或行数与预览不一致时停止保存,不覆盖用户选定的输出文件。打包引擎通过 `RECEIPT_ENGINE_BINARY` 运行 `test_exports.py``engine_process` 测试。最终应用的引擎带沙盒继承签名,不能直接从普通终端启动;应使用 Debug 应用的 `--verify-local-engine` 自检入口,从真实应用内验证两条汇总并检查生成的 `grouped-expense.xlsx`,而不能仅验证 Python 源码。
+2
View File
@@ -20,6 +20,8 @@ RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_ppt_portrait.py -k engine_process -v
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_approved_ppt.py -k engine_process -v
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_match_explanation.py -k engine_process -v
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_expense_template.py -k engine_process -v
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_expense_preview.py -k engine_process -v
if [ "$SOURCE_HASH" != "$(bash "$ROOT/native-engine/engine-fingerprint.sh")" ]; then
echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2
exit 1
+16 -3
View File
@@ -9,6 +9,8 @@ from pathlib import Path
from domain import auto_match, enrich, extract
from exports import export_expense, export_ppt, export_travel
from expense_template import export_custom_expense, inspect_template, validate_template
from expense_preview import prepare_preview
PROTOCOL = sys.stdout
SUPPORTED = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif', '.pdf'}
@@ -83,6 +85,10 @@ def dispatch(request):
operation = request['operation']
if operation == 'scan':
return scan(request)
if operation == 'inspect-expense-template':
return inspect_template(request['templatePath'])
if operation == 'validate-expense-template':
return validate_template(request['templatePath'], request['templateMapping'])
state = request['state']
if operation == 'refresh':
return enrich(state)
@@ -97,9 +103,16 @@ def dispatch(request):
elif operation == 'travel':
export_travel(state, temporary)
elif operation == 'expense':
template = Path(__file__).parent / 'personal-expense-template.xlsx'
metadata = export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []),
request.get('payee', {}), request.get('categoryPurposes'))
if request.get('templatePath'):
metadata = export_custom_expense(state, temporary, request['templatePath'], request.get('templateMapping'),
request.get('purposes', {}), request.get('signatures', []),
request.get('payee', {}), request.get('categoryPurposes'))
else:
template = Path(__file__).parent / 'personal-expense-template.xlsx'
metadata = export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []),
request.get('payee', {}), request.get('categoryPurposes'))
if request.get('preparePreview'):
metadata.update(prepare_preview(temporary))
else:
raise ValueError('未知操作:' + operation)
os.replace(temporary, destination)
+220
View File
@@ -0,0 +1,220 @@
import ast
import datetime
import re
import zipfile
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from expense_template import Template, column_name, coordinate, encoded, tag
class UnsupportedFormula(ValueError):
pass
RMB_FORMULA = ('SUBSTITUTE(SUBSTITUTE(IF({cell}>-0.5%,,"")&TEXT(INT(ABS({cell})+0.5%),'
'"[dbnum2]G/通用格式元;;")&TEXT(RIGHT(FIXED({cell}),2),"[dbnum2]0角0分;;"&'
'IF(ABS({cell})>1%,"",)),"零角",IF(ABS({cell})<1,,"")),"零分","")')
def chinese_integer(number):
digits = '零壹贰叁肆伍陆柒捌玖'
if not 0 <= number < 10 ** 12:
raise UnsupportedFormula('大写金额超出预览范围')
if number == 0:
return ''
result, pending_zero = '', False
for divisor, suffix in [(10 ** 8, '亿'), (10 ** 4, ''), (1, '')]:
group, number = divmod(number, divisor)
if not group:
if result:
pending_zero = True
continue
if result and (pending_zero or group < 1000):
result += ''
fragment, zero = '', False
for unit, label in [(1000, ''), (100, ''), (10, ''), (1, '')]:
digit, group = divmod(group, unit)
if digit:
if zero:
fragment += ''
fragment += digits[digit] + label
zero = False
elif fragment:
zero = True
result += fragment + suffix
pending_zero = False
return result
def builtin_rmb(value):
absolute = abs(value)
integer = int(absolute + Decimal('0.005'))
cents = int(absolute.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) * 100) % 100
result = '' if value > Decimal('-0.005') else ''
if integer:
result += chinese_integer(integer) + ''
digits = '零壹贰叁肆伍陆柒捌玖'
result += (digits[cents // 10] + '' + digits[cents % 10] + '') if cents else ('' if absolute > Decimal('0.01') else '')
return result.replace('零角', '' if absolute < 1 else '').replace('零分', '')
class PreviewCalculator:
def __init__(self, document, date_1904=False):
self.cells = {cell.get('r'): cell for cell in document.iter(tag('c'))}
self.values = {}
self.visiting = set()
self.epoch = datetime.date(1904, 1, 1) if date_1904 else datetime.date(1899, 12, 30)
def cell(self, reference):
if reference in self.values:
return self.values[reference]
if reference in self.visiting or len(self.visiting) >= 100:
raise UnsupportedFormula('循环引用或嵌套过深')
cell = self.cells.get(reference)
if cell is None:
return Decimal(0)
self.visiting.add(reference)
try:
formula = cell.find(tag('f'))
if formula is not None:
if formula.get('t') not in (None, 'normal'):
raise UnsupportedFormula('数组或共享公式')
result = self.expression(formula.text or '')
elif cell.get('t') in (None, 'n'):
result = Decimal(cell.findtext(tag('v')) or '0')
elif cell.get('t') == 'inlineStr' and not ''.join(cell.itertext()).strip():
result = Decimal(0)
else:
raise UnsupportedFormula('非数值单元格')
if isinstance(result, Decimal) and not result.is_finite():
raise UnsupportedFormula('非有限数值')
self.values[reference] = result
return result
finally:
self.visiting.remove(reference)
def expression(self, formula):
formula = formula.strip().replace('$', '')
if len(formula) > 2000:
raise UnsupportedFormula('公式过长')
if formula.startswith('SUBSTITUTE(SUBSTITUTE(IF('):
matched = re.match(r'SUBSTITUTE\(SUBSTITUTE\(IF\(([A-Z]+[0-9]+)>', formula)
if matched and formula == RMB_FORMULA.format(cell=matched[1]):
amount = self.cell(matched[1])
if not isinstance(amount, Decimal):
raise UnsupportedFormula('大写金额来源不是数值')
return builtin_rmb(amount)
if formula.upper() == 'TODAY()':
return Decimal((datetime.date.today() - self.epoch).days)
matched = re.fullmatch(r'SUM\(([A-Z]+[0-9]+):([A-Z]+[0-9]+)\)', formula, re.I)
if matched:
left, top = coordinate(matched[1].upper())
right, bottom = coordinate(matched[2].upper())
if right < left or bottom < top or (right - left + 1) * (bottom - top + 1) > 20000:
raise UnsupportedFormula('求和范围过大')
total = Decimal(0)
for row in range(top, bottom + 1):
for column in range(left, right + 1):
reference = column_name(column) + str(row)
cell = self.cells.get(reference)
if cell is not None and cell.find(tag('f')) is None and cell.get('t') not in (None, 'n'):
continue
value = self.cell(reference)
if not isinstance(value, str):
total += value
return total
if formula.upper().startswith('IF(') and formula.endswith(')'):
arguments = []
depth, start = 0, 3
for index in range(3, len(formula) - 1):
character = formula[index]
if character == '(':
depth += 1
elif character == ')':
depth -= 1
elif character == ',' and depth == 0:
arguments.append(formula[start:index])
start = index + 1
arguments.append(formula[start:-1])
if len(arguments) != 3:
raise UnsupportedFormula('不支持的条件公式')
return self.expression(arguments[1] if self.expression(arguments[0]) else arguments[2])
formula = formula.replace('<>', '!=')
formula = re.sub(r'(?<![<>=!])=(?!=)', '==', formula)
tree = ast.parse(formula, mode='eval')
return self.node(tree.body)
def node(self, node):
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
return Decimal(str(node.value))
if isinstance(node, ast.Name) and re.fullmatch(r'[A-Z]{1,3}[1-9][0-9]{0,3}', node.id, re.I):
return self.cell(node.id.upper())
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
value = self.node(node.operand)
if isinstance(value, str):
raise UnsupportedFormula('非数值运算')
return -value if isinstance(node.op, ast.USub) else value
if isinstance(node, ast.BinOp):
left, right = self.node(node.left), self.node(node.right)
if isinstance(left, str) or isinstance(right, str):
raise UnsupportedFormula('非数值运算')
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if isinstance(node.op, ast.Div):
return left / right
if isinstance(node, ast.Compare) and len(node.ops) == 1:
left, right = self.node(node.left), self.node(node.comparators[0])
if isinstance(left, str) or isinstance(right, str):
raise UnsupportedFormula('非数值比较')
operator = node.ops[0]
if isinstance(operator, ast.Gt):
return left > right
if isinstance(operator, ast.GtE):
return left >= right
if isinstance(operator, ast.Lt):
return left < right
if isinstance(operator, ast.LtE):
return left <= right
if isinstance(operator, ast.Eq):
return left == right
if isinstance(operator, ast.NotEq):
return left != right
raise UnsupportedFormula('自定义公式需由 Excel / WPS 计算')
def prepare_preview(path):
from lxml import etree as ET
template = Template(path)
properties = template.workbook.find(tag('workbookPr'))
date_1904 = properties is not None and properties.get('date1904') in ('1', 'true')
uncalculated = []
for sheet in template.sheets:
if sheet.get('state', 'visible') != 'visible':
continue
_, sheet_path, document = template.sheet(sheet.get('name'))
calculator = PreviewCalculator(document, date_1904)
for cell in document.iter(tag('c')):
if cell.find(tag('f')) is None:
continue
for cached in cell.findall(tag('v')):
cell.remove(cached)
try:
value = calculator.cell(cell.get('r'))
cell.set('t', 'str' if isinstance(value, str) else 'b' if isinstance(value, bool) else 'n')
ET.SubElement(cell, tag('v')).text = str(int(value)) if isinstance(value, bool) else str(value)
except (ValueError, SyntaxError, ArithmeticError, InvalidOperation, RecursionError):
uncalculated.append(sheet.get('name') + '!' + cell.get('r'))
template.parts[sheet_path] = encoded(document)
for name, document in [('xl/workbook.xml', template.workbook),
('xl/_rels/workbook.xml.rels', template.relations),
('[Content_Types].xml', template.types)]:
template.parts[name] = encoded(document)
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as output:
for name, data in template.parts.items():
output.writestr(name, data)
return dict(expensePreview='xlsx-quicklook-v1', previewUncalculatedCells=uncalculated)
+440
View File
@@ -0,0 +1,440 @@
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)
+118
View File
@@ -0,0 +1,118 @@
import datetime
import json
import os
import subprocess
import sys
import unittest
from decimal import Decimal
from pathlib import Path
from openpyxl import load_workbook
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_expense_template as fixtures
from expense_preview import PreviewCalculator, UnsupportedFormula, builtin_rmb, prepare_preview
from expense_template import Template, encoded, set_cell, tag
class ExpensePreviewTests(unittest.TestCase):
def setUp(self):
self.fixture = fixtures.ExpenseTemplateTests()
self.fixture.setUp()
self.addCleanup(self.fixture.tearDown)
def test_filled_workbook_previews_current_totals_and_preserves_formulas(self):
output, _ = self.fixture.export()
result = prepare_preview(output)
values = load_workbook(output, data_only=True)
formulas = load_workbook(output)
sheet = values.active
self.assertEqual(sheet['C9'].value, '交通')
self.assertEqual(sheet['H9'].value, 100.3)
self.assertEqual(sheet['H10'].value, 444)
self.assertEqual(sheet['F27'].value, 544.3)
self.assertEqual(sheet['H29'].value, 544.3)
self.assertEqual(formulas.active['F27'].value, '=SUM(H9:H21)')
self.assertEqual(formulas.active['H29'].value, '=IF(F27>C27,F27-C27,0)')
self.assertEqual(result['previewUncalculatedCells'], [])
self.assertEqual(sheet['D29'].value, '伍佰肆拾肆元叁角整')
self.assertEqual(result['expensePreview'], 'xlsx-quicklook-v1')
values.close()
formulas.close()
def test_builtin_uppercase_amount_formula(self):
for amount, expected in [('3491.86', '叁仟肆佰玖拾壹元捌角陆分'), ('10001.01', '壹万零壹元零壹分'),
('100000001', '壹亿零壹元整'), ('100010000', '壹亿零壹万元整'),
('0.01', '壹分'), ('0.1', '壹角整'), ('-50.02', '负伍拾元零贰分'),
('0', ''), ('1.999', '贰元整'), ('999999999999.99', '玖仟玖佰玖拾玖亿玖仟玖佰玖拾玖万玖仟玖佰玖拾玖元玖角玖分')]:
with self.subTest(amount=amount):
self.assertEqual(builtin_rmb(Decimal(amount)), expected)
def test_caches_each_custom_page_not_whole_report(self):
source, mapping = self.fixture.alternate()
state = dict(matches=self.fixture.state['matches'] + [self.fixture.match('third', '住宿', '50')])
output, _ = self.fixture.export(mapping=mapping, template=source, state=state)
prepare_preview(output)
workbook = load_workbook(output, data_only=True)
self.assertEqual(workbook['组 B 报销']['F8'].value, 544.3)
self.assertEqual(workbook['组 B 报销-续2']['F8'].value, 50)
self.assertIn('保留说明', workbook.sheetnames)
workbook.close()
def test_clears_unknown_cache_and_does_not_execute_formula(self):
output, _ = self.fixture.export()
template = Template(output)
_, path, document = template.sheet('个人报销单')
set_cell(document, 'I35', '__import__("os").system("touch /tmp/should-not-run")', formula=True)
from lxml import etree as ET
ET.SubElement(document.find('.//' + tag('c') + '[@r="I35"]'), tag('v')).text = '9999'
template.parts[path] = encoded(document)
self.fixture.rewrite(output, template.parts)
result = prepare_preview(output)
self.assertIn('个人报销单!I35', result['previewUncalculatedCells'])
workbook = load_workbook(output, data_only=True)
self.assertIsNone(workbook.active['I35'].value)
workbook.close()
def test_calculator_cycles_text_dates_and_conditionals(self):
template = Template(self.fixture.template)
_, _, sheet = template.sheet('个人报销单')
set_cell(sheet, 'J1', 10)
set_cell(sheet, 'J2', '文字')
set_cell(sheet, 'J3', '-J1*2+5', formula=True)
set_cell(sheet, 'J4', 'J5', formula=True)
set_cell(sheet, 'J5', 'J4', formula=True)
calculator = PreviewCalculator(sheet)
self.assertEqual(calculator.cell('J3'), -15)
self.assertEqual(calculator.expression('SUM(J1:J3)'), -5)
self.assertEqual(calculator.expression('IF(J1>0,J1/2,1/0)'), 5)
self.assertEqual(calculator.expression('TODAY()'), (datetime.date.today() - datetime.date(1899, 12, 30)).days)
self.assertEqual(PreviewCalculator(sheet, True).expression('TODAY()'),
(datetime.date.today() - datetime.date(1904, 1, 1)).days)
with self.assertRaises(UnsupportedFormula):
calculator.cell('J4')
with self.assertRaises(UnsupportedFormula):
calculator.expression('2**10000000')
def test_engine_process_preview_builtin_and_imported_templates(self):
for custom in [False, True]:
with self.subTest(custom=custom):
output = self.fixture.root / ('custom-preview.xlsx' if custom else 'builtin-preview.xlsx')
request = dict(operation='expense', state=self.fixture.state, destination=str(output), preparePreview=True)
if custom:
request.update(templatePath=str(self.fixture.template), templateMapping=self.fixture.mapping)
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
process = subprocess.run(command, input=json.dumps(request) + '\n', text=True, capture_output=True, timeout=60)
self.assertEqual(process.returncode, 0, process.stderr + process.stdout)
result = next(json.loads(line)['result'] for line in process.stdout.splitlines() if json.loads(line).get('event') == 'result')
self.assertEqual(result['expensePreview'], 'xlsx-quicklook-v1')
self.assertEqual(result['expenseRowCount'], 2)
workbook = load_workbook(output, data_only=True)
self.assertEqual(workbook.active['F27'].value, 544.3)
self.assertEqual(workbook.active['H29'].value, 544.3)
self.assertIn(workbook.active['C23'].value, ('', None))
workbook.close()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,288 @@
import copy
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from lxml import etree as ET
from openpyxl import load_workbook
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from expense_template import (CONTENT, MAIN, PACKAGE, REL, Template, encoded, export_custom_expense,
inspect_template, set_cell, tag, validate_template, xml)
from exports import export_expense
from test_domain import material, workspace
class ExpenseTemplateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.builtin = Path(__file__).resolve().parents[1] / 'personal-expense-template.xlsx'
self.state = workspace([], [])
self.state['matches'] = [self.match('traffic-1', '交通', '30.10'), self.match('traffic-2', '交通', '70.20'),
self.match('office', '办公用品', '444.00')]
self.template = self.root / '旧报销单.xlsx'
old = dict(matches=[self.match('old-' + str(index), '旧类别' + str(index), '999.00') for index in range(7)])
export_expense(old, self.template, self.builtin, {}, ['旧岗位'],
dict(recipient='旧收款人', bankName='旧开户行', accountNumber='9999999', preparer='旧制单人'))
self.mapping = inspect_template(self.template)['sheets'][0]['mapping']
def tearDown(self):
self.temporary.cleanup()
def match(self, identifier, category, amount):
return dict(id=identifier, category=category, invoices=[material(identifier, 'invoice', amount)], payments=[])
def export(self, mapping=None, template=None, state=None, payee=None):
output = self.root / 'output.xlsx'
metadata = export_custom_expense(state or self.state, output, template or self.template, mapping or self.mapping,
{}, ['经办人', '财务'], payee or {},
{'交通': '交通', '办公用品': '=办公用品'})
return output, metadata
def rewrite(self, path, parts):
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
for name, data in parts.items():
archive.writestr(name, data)
def alternate(self):
template = Template(self.template)
sheet, path, _ = template.sheet('个人报销单')
sheet.set('name', '组 B 报销')
document = ET.Element(tag('worksheet'), nsmap={None: MAIN})
ET.SubElement(document, tag('sheetData'))
values = {'A1': '自定义组 B 报销单', 'A4': '序号', 'B4': '用途', 'D4': '单据数量', 'F4': '金额',
'G4': '备注', 'B5': '旧内容1', 'F5': 500, 'G5': '旧备注', 'B6': '旧内容2', 'F6': 800,
'A10': '收款人', 'B10': '旧收款人', 'A11': '开户行', 'B11': '旧银行',
'A12': '账号', 'B12': '旧账号', 'E2': '制单人', 'F2': '旧人', 'E8': '报销合计',
'A14': '要清空的旧值', 'A15': '旧签字', 'F15': '旧岗位', 'A16': '保留的自定义说明'}
for reference, value in values.items():
set_cell(document, reference, value)
set_cell(document, 'F8', 'SUM(F5:F6)', formula=True)
merges = ET.SubElement(document, tag('mergeCells'), count='3')
for reference in ['B4:C4', 'B5:C5', 'B6:C6']:
ET.SubElement(merges, tag('mergeCell'), ref=reference)
ET.SubElement(document, tag('pageMargins'), left='0.2', right='0.2', top='0.3', bottom='0.3', header='0', footer='0')
ET.SubElement(document, tag('pageSetup'), paperSize='9', orientation='landscape', fitToWidth='1')
template.parts[path] = encoded(document)
names = template.workbook.find(tag('definedNames'))
for item in list(names):
names.remove(item)
ET.SubElement(names, tag('definedName'), name='_xlnm.Print_Area', localSheetId='0').text = "'组 B 报销'!$A$1:$G$16"
sheet_list = template.workbook.find(tag('sheets'))
ET.SubElement(sheet_list, tag('sheet'), name='保留说明', sheetId='2', attrib={'{' + REL + '}id': 'rIdNotes'})
ET.SubElement(template.relations, '{' + PACKAGE + '}Relationship', Id='rIdNotes', Type=REL + '/worksheet',
Target='worksheets/notes.xml')
ET.SubElement(template.types, '{' + CONTENT + '}Override', PartName='/xl/worksheets/notes.xml',
ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
notes = ET.Element(tag('worksheet'), nsmap={None: MAIN})
ET.SubElement(notes, tag('sheetData'))
set_cell(notes, 'A1', '保持这个工作表不变')
template.parts['xl/worksheets/notes.xml'] = encoded(notes)
template.parts['xl/workbook.xml'] = encoded(template.workbook)
template.parts['xl/_rels/workbook.xml.rels'] = encoded(template.relations)
template.parts['[Content_Types].xml'] = encoded(template.types)
target = self.root / 'different-layout.xlsx'
self.rewrite(target, template.parts)
mapping = inspect_template(target)['sheets'][0]['mapping']
mapping['signatureCells'] = 'A15,F15'
mapping['clearCells'] = 'A14'
return target, mapping
def test_auto_detect_uploaded_style_and_merged_signature_slots(self):
self.assertEqual(self.mapping['sheetName'], '个人报销单')
self.assertEqual((self.mapping['startRow'], self.mapping['endRow']), (9, 21))
self.assertEqual(self.mapping['columns'], dict(purpose='C', amount='H', count='G', invoiceType='B', sequence='A', remarks='I'))
self.assertEqual(self.mapping['cells'], dict(recipient='C23', bankName='C24', accountNumber='C25', preparer='H6', total='F27'))
self.assertNotIn('D32', self.mapping['signatureCells'])
validation = validate_template(self.template, self.mapping)
self.assertEqual(validation['mapping'], self.mapping)
self.assertEqual(len(validation['fingerprint']), 64)
def test_replaces_old_seven_rows_with_two_aggregated_categories(self):
original = self.template.read_bytes()
output, metadata = self.export()
self.assertEqual(metadata['expenseRowCount'], 2)
self.assertEqual(metadata['expensePageCount'], 1)
workbook = load_workbook(output)
sheet = workbook.active
self.assertEqual(sheet['C9'].value, '交通')
self.assertEqual(sheet['H9'].value, 100.30)
self.assertEqual(sheet['G9'].value, 2)
self.assertEqual(sheet['C10'].value, '=办公用品')
self.assertEqual(sheet['C10'].data_type, 's')
self.assertEqual(sheet['H10'].value, 444)
for row in range(11, 22):
for column in ['A', 'B', 'C', 'G', 'H', 'I']:
self.assertIn(sheet[f'{column}{row}'].value, ('', None))
for reference in ['C23', 'C24', 'C25', 'H6']:
self.assertIn(sheet[reference].value, ('', None))
self.assertEqual(sheet['F27'].value, '=SUM(H9:H21)')
self.assertEqual(sheet['A30'].value, '经办人:')
self.assertEqual(sheet['D30'].value, '财务:')
self.assertEqual(self.template.read_bytes(), original)
workbook.close()
def test_preserves_styles_merges_print_settings_and_media(self):
output, _ = self.export()
with zipfile.ZipFile(self.template) as original, zipfile.ZipFile(output) as generated:
for name in original.namelist():
if name == 'xl/styles.xml' or name.startswith('xl/media/') or name.startswith('xl/drawings/'):
self.assertEqual(original.read(name), generated.read(name), name)
old, new = load_workbook(self.template), load_workbook(output)
for reference in ['A1', 'A3', 'A4', 'C9', 'H9', 'C25']:
self.assertEqual(old.active[reference].style_id, new.active[reference].style_id)
self.assertEqual(str(old.active.merged_cells), str(new.active.merged_cells))
self.assertEqual(str(old.active.print_area), str(new.active.print_area))
self.assertEqual(old.active.page_setup, new.active.page_setup)
self.assertEqual(old.active['A3'].value, new.active['A3'].value)
self.assertEqual(old.active['A4'].value, new.active['A4'].value)
self.assertEqual(old.active['D29'].value, new.active['D29'].value)
old.close()
new.close()
def test_alternate_layout_preserves_other_sheet_and_paginates(self):
template, mapping = self.alternate()
self.assertEqual(mapping['columns']['purpose'], 'B')
self.assertEqual((mapping['startRow'], mapping['endRow']), (5, 6))
self.assertEqual(mapping['cells']['total'], 'F8')
state = dict(matches=self.state['matches'] + [self.match('hotel', '住宿', '9.99')])
output, result = self.export(mapping, template, state,
dict(recipient='新收款人', bankName='=新银行', accountNumber='0001234567890123456789'))
self.assertEqual(result['expensePageCount'], 2)
workbook = load_workbook(output)
self.assertEqual(workbook.sheetnames, ['组 B 报销', '保留说明', '组 B 报销-续2'])
self.assertEqual(workbook['保留说明']['A1'].value, '保持这个工作表不变')
for sheet in [workbook['组 B 报销'], workbook['组 B 报销-续2']]:
self.assertEqual(sheet['B10'].value, '新收款人')
self.assertEqual(sheet['F2'].value, '新收款人')
self.assertEqual(sheet['B11'].data_type, 's')
self.assertEqual(sheet['B12'].value, '0001234567890123456789')
self.assertEqual(sheet['B12'].data_type, 's')
self.assertEqual(sheet['F8'].value, '=SUM(F5:F6)')
self.assertEqual(sheet['A16'].value, '保留的自定义说明')
self.assertIn(sheet['A14'].value, ('', None))
self.assertEqual(sheet['A15'].value, '经办人:')
self.assertEqual(sheet.page_setup.orientation, 'landscape')
self.assertTrue(str(sheet.print_area).endswith('!$A$1:$G$16'))
self.assertEqual(workbook['组 B 报销-续2']['B5'].value, '住宿')
self.assertEqual(workbook['组 B 报销-续2']['A5'].value, 3)
self.assertIn(workbook['组 B 报销-续2']['B6'].value, ('', None))
workbook.close()
def test_unmapped_signature_cells_preserve_template(self):
mapping = copy.deepcopy(self.mapping)
mapping['signatureCells'] = ''
output, _ = self.export(mapping)
book = load_workbook(output)
self.assertEqual(book.active['A30'].value, '旧岗位:')
book.close()
def test_mapping_normalizes_lowercase(self):
mapping = copy.deepcopy(self.mapping)
mapping['columns']['purpose'] = ' c '
mapping['cells']['recipient'] = ' c23 '
mapping['signatureCells'] = 'a30 d30'
result = validate_template(self.template, mapping)
self.assertEqual(result['mapping']['columns']['purpose'], 'C')
self.assertEqual(result['mapping']['cells']['recipient'], 'C23')
self.assertEqual(result['mapping']['signatureCells'], 'A30,D30')
def test_invalid_mappings_do_not_write_output(self):
mutations = [
lambda mapping: mapping.update(startRow=22, endRow=9),
lambda mapping: mapping.update(startRow=0),
lambda mapping: mapping.update(endRow=300),
lambda mapping: mapping.update(sheetName='不存在'),
lambda mapping: mapping['columns'].update(purpose='D'),
lambda mapping: mapping['columns'].update(count='H'),
lambda mapping: mapping['columns'].update(amount=''),
lambda mapping: mapping['columns'].update(amount='A1'),
lambda mapping: mapping['cells'].update(recipient='C9'),
lambda mapping: mapping['cells'].update(bankName='C23'),
lambda mapping: mapping.update(signatureCells='D32'),
lambda mapping: mapping.update(clearCells='D23'),
lambda mapping: mapping.update(clearCells='C9'),
]
for mutate in mutations:
mapping = copy.deepcopy(self.mapping)
mutate(mapping)
with self.subTest(mapping=mapping), self.assertRaises(ValueError):
self.export(mapping)
self.assertFalse((self.root / 'output.xlsx').exists())
def test_corrupt_and_non_xlsx_rejected(self):
for name in ['bad.xlsx', 'old.xls', 'macro.xlsm']:
path = self.root / name
path.write_bytes(b'not an xlsx')
with self.assertRaises(ValueError):
inspect_template(path)
def test_live_external_formula_rejected_but_orphans_removed(self):
template = Template(self.template)
_, path, document = template.sheet('个人报销单')
set_cell(document, 'J1', "'[1]Sheet1'!A1", formula=True)
template.parts[path] = encoded(document)
live = self.root / 'external.xlsx'
self.rewrite(live, template.parts)
with self.assertRaisesRegex(ValueError, '外部'):
inspect_template(live)
output, _ = self.export()
with zipfile.ZipFile(output) as archive:
self.assertFalse(any(name.startswith('xl/externalLinks/') for name in archive.namelist()))
def test_formula_caches_are_invalidated(self):
output, _ = self.export()
template = Template(output)
_, _, document = template.sheet('个人报销单')
self.assertTrue(list(document.iter(tag('f'))))
for cell in document.iter(tag('c')):
if cell.find(tag('f')) is not None:
self.assertIsNone(cell.find(tag('v')))
self.assertEqual(template.workbook.find(tag('calcPr')).get('calcMode'), 'auto')
self.assertNotIn('xl/calcChain.xml', template.parts)
def engine(self, request):
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
process = subprocess.run(command, input=json.dumps(request), capture_output=True, text=True, timeout=60)
return process, json.loads(process.stdout.splitlines()[-1])
def test_engine_process_inspects_validates_and_uses_custom_template(self):
process, inspected = self.engine(dict(operation='inspect-expense-template', templatePath=str(self.template)))
self.assertEqual(process.returncode, 0, process.stderr)
self.assertEqual(inspected['result']['sheets'][0]['mapping'], self.mapping)
process, validated = self.engine(dict(operation='validate-expense-template', templatePath=str(self.template), templateMapping=self.mapping))
self.assertEqual(process.returncode, 0, process.stderr)
output = self.root / 'protocol.xlsx'
process, result = self.engine(dict(operation='expense', state=self.state, destination=str(output),
templatePath=str(self.template), templateMapping=self.mapping,
payee=dict(recipient='新用户', accountNumber='0009876543210987654321')))
self.assertEqual(process.returncode, 0, process.stderr)
self.assertEqual(result['result']['templateFingerprint'], hashlib.sha256(self.template.read_bytes()).hexdigest())
self.assertEqual(result['result']['templateMappingDigest'], validated['result']['mappingDigest'])
self.assertEqual(result['result']['expenseRowCount'], 2)
book = load_workbook(output)
self.assertEqual(book.active['C23'].value, '新用户')
self.assertEqual(book.active['H9'].value, 100.30)
book.close()
def test_engine_process_missing_custom_template_never_falls_back(self):
output = self.root / 'existing.xlsx'
output.write_bytes(b'keep existing output')
process, result = self.engine(dict(operation='expense', state=self.state, destination=str(output),
templatePath=str(self.root / 'missing.xlsx'), templateMapping=self.mapping))
self.assertNotEqual(process.returncode, 0)
self.assertEqual(result['event'], 'error')
self.assertEqual(output.read_bytes(), b'keep existing output')
self.assertFalse(list(self.root.glob('.*.xlsx')))
if __name__ == '__main__':
unittest.main()
+11 -2
View File
@@ -69,6 +69,11 @@ struct ContentView: View {
.tint(.teal)
.frame(minWidth: 1050, minHeight: 700)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: {
Label("报销模板", systemImage: "doc.text")
}.disabled(store.busy)
}
ToolbarItem(placement: .primaryAction) {
Button { showPayeeProfile = true } label: {
Label("收款信息", systemImage: "person.crop.rectangle")
@@ -83,12 +88,16 @@ struct ContentView: View {
Button("知道了") { store.errorMessage = nil }
} message: { Text(store.errorMessage ?? "") }
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
Button("清空材料与核对结果", role: .destructive, action: store.clear)
Button("清空本次报销数据(保留模板)", role: .destructive) { store.clear() }
if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil {
Button("报销数据和导入模板一起清空", role: .destructive) { store.clear(includeTemplate: true) }
}
Button("取消", role: .cancel) {}
} message: { Text("当前工作区的副本和核对结果将被移除,您选择的原始文件夹不会被修改") }
} message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除") }
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
.sheet(isPresented: $showPayeeProfile) { PayeeProfileSheet(profileStore: store.payeeProfile) }
.sheet(isPresented: $store.showExpenseTemplate) { ExpenseTemplateManager() }
}
private var topbar: some View {
+49
View File
@@ -0,0 +1,49 @@
import Foundation
enum ExpenseTemplateImportSource {
static func validate(_ sources: [URL]) throws -> URL {
guard sources.count == 1, let source = sources.first else {
throw ExpenseTemplateFailure.message("请一次拖入一个 Excel 模板文件。")
}
guard source.isFileURL, source.pathExtension.lowercased() == "xlsx" else {
throw ExpenseTemplateFailure.message("请从访达拖入 .xlsx 文件,不是文件夹或网页链接;.xls 请先另存为 .xlsx。")
}
let values = try source.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .isReadableKey])
guard values.isRegularFile == true, values.isReadable == true else {
throw ExpenseTemplateFailure.message("无法读取模板文件,请用“选择文件”重新授权。")
}
guard let size = values.fileSize, size > 0, size <= 20 * 1024 * 1024 else {
throw ExpenseTemplateFailure.message("请选择不为空、且不超过 20 MB 的 .xlsx 文件。")
}
return source
}
}
struct PreparedExpensePreview {
let file: URL
let requestDigest: String
let fileDigest: String
let templateName: String
let uncalculatedCells: [String]
static func digest(_ request: [String: Any]) throws -> String {
var inputs = request
inputs.removeValue(forKey: "destination")
return ExpenseTemplateStorage.fingerprint(try JSONSerialization.data(withJSONObject: inputs, options: [.sortedKeys]))
}
func verifiedData(request: [String: Any]) throws -> Data {
guard try Self.digest(request) == requestDigest else {
throw ExpenseTemplateFailure.message("报销内容、收款信息或模板已变化,请重新生成预览后保存。")
}
let data = try Data(contentsOf: file)
guard ExpenseTemplateStorage.fingerprint(data) == fileDigest else {
throw ExpenseTemplateFailure.message("预览文件已变化,请重新生成预览;不会保存与预览不一致的内容。")
}
return data
}
func remove() {
try? FileManager.default.removeItem(at: file)
}
}
+84
View File
@@ -0,0 +1,84 @@
import SwiftUI
import Quartz
struct SpreadsheetPreview: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> QLPreviewView {
let view = QLPreviewView(frame: .zero, style: .normal)!
view.shouldCloseWithWindow = false
view.autostarts = true
view.previewItem = url as NSURL
return view
}
func updateNSView(_ view: QLPreviewView, context: Context) {
if view.previewItem?.previewItemURL != url {
view.previewItem = url as NSURL
}
}
static func dismantleNSView(_ view: QLPreviewView, coordinator: ()) {
view.close()
}
}
struct ExpensePreviewSheet: View {
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 5) {
Text("报销单预览").font(.title2.bold())
Text("已选 \(store.expenseMatchIDs.count) 组 · 合并为 \(store.expenseRows.count) 条费用 · \(store.expensePreview?.templateName ?? store.expenseTemplateName)")
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Spacer()
Text("合计 \(currency(store.expenseRows.reduce(0) { $0 + $1.amountValue }))")
.font(.title3.bold()).monospacedDigit()
}.padding(20)
Divider()
if store.busy {
VStack(spacing: 14) {
ProgressView()
Text("正在填入所选费用、收款资料和签字岗位…")
Button("取消生成", action: store.cancel)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let preview = store.expensePreview {
SpreadsheetPreview(url: preview.file).id(preview.file)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView("暂时无法生成预览", systemImage: "doc.badge.ellipsis",
description: Text("请检查模板及收款信息,或重试。"))
}
if let error = store.expensePreviewError {
Label(error, systemImage: "exclamationmark.triangle")
.font(.callout).foregroundStyle(.red).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 20).padding(.vertical, 10)
}
if let preview = store.expensePreview, !preview.uncalculatedCells.isEmpty {
Text("部分自定义公式尚未计算:\(preview.uncalculatedCells.prefix(5).joined(separator: ""))。这些位置在预览中留空,Excel / WPS 打开后会重算。")
.font(.caption).foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 20).padding(.bottom, 10)
}
Divider()
HStack {
Text("预览的是待保存的 Excel;确认后保存同一份文件。")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button("返回修改") { dismiss() }.keyboardShortcut(.cancelAction)
if store.expensePreviewError != nil || store.expensePreview == nil {
Button("重新预览", action: store.prepareExpensePreview)
}
Button("确认并保存…", action: store.saveExpensePreview)
.buttonStyle(.borderedProminent)
.disabled(store.expensePreview == nil || store.expensePreviewError != nil)
}.padding(20).disabled(store.busy)
}
.frame(width: 980, height: 720)
.interactiveDismissDisabled(store.busy)
.onDisappear { store.discardExpensePreview() }
}
}
+132
View File
@@ -0,0 +1,132 @@
import Foundation
import CryptoKit
struct ExpenseTemplateMapping: Codable, Equatable {
var sheetName = ""
var startRow = 9
var endRow = 21
var columns: [String: String] = [:]
var cells: [String: String] = [:]
var signatureCells = ""
var clearCells = ""
}
struct ExpenseTemplateSheet: Codable, Identifiable {
var name: String
var mapping: ExpenseTemplateMapping
var preview: [String]
var id: String { name }
}
struct ExpenseTemplateInspection: Codable {
var templateVersion: String
var fingerprint: String
var sheets: [ExpenseTemplateSheet]
}
struct ExpenseTemplateValidation: Codable {
var templateVersion: String
var fingerprint: String
var mapping: ExpenseTemplateMapping
var mappingDigest: String
}
struct SavedExpenseTemplate: Codable {
var name: String
var fileName: String
var fingerprint: String
var mappingDigest: String
var mapping: ExpenseTemplateMapping
}
struct ExpenseTemplateDraft: Identifiable {
var id = UUID()
var name: String
var source: URL
var inspection: ExpenseTemplateInspection
var mapping: ExpenseTemplateMapping
}
enum ExpenseTemplateFailure: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
final class ExpenseTemplateStorage {
let directory: URL
var manifest: URL { directory.appendingPathComponent("current.json") }
init(root: URL) {
directory = root.appendingPathComponent("expense-templates", isDirectory: true)
}
func load() throws -> SavedExpenseTemplate? {
guard FileManager.default.fileExists(atPath: manifest.path) else { return nil }
return try JSONDecoder().decode(SavedExpenseTemplate.self, from: Data(contentsOf: manifest))
}
func file(for template: SavedExpenseTemplate) throws -> URL {
guard UUID(uuidString: String(template.fileName.dropLast(5))) != nil, template.fileName.hasSuffix(".xlsx") else {
throw ExpenseTemplateFailure.message("模板记录损坏,请重新导入模板")
}
let file = directory.appendingPathComponent(template.fileName)
guard FileManager.default.fileExists(atPath: file.path) else {
throw ExpenseTemplateFailure.message("已导入的模板副本丢失,请重新导入;不会自动改用默认模板")
}
let data = try Data(contentsOf: file)
guard Self.fingerprint(data) == template.fingerprint else {
throw ExpenseTemplateFailure.message("模板副本已发生变化,请重新导入并确认填充位置")
}
return file
}
static func fingerprint(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
func save(name: String, source: URL, validation: ExpenseTemplateValidation) throws -> SavedExpenseTemplate {
guard validation.templateVersion == "mapped-xlsx-v1" else {
throw ExpenseTemplateFailure.message("模板引擎版本不正确,请重新构建应用")
}
let data = try Data(contentsOf: source)
guard Self.fingerprint(data) == validation.fingerprint else {
throw ExpenseTemplateFailure.message("验证后模板内容发生变化,请重新导入")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let old = try? load()
let next = SavedExpenseTemplate(name: name, fileName: UUID().uuidString + ".xlsx",
fingerprint: validation.fingerprint, mappingDigest: validation.mappingDigest,
mapping: validation.mapping)
let destination = directory.appendingPathComponent(next.fileName)
try data.write(to: destination, options: .atomic)
do {
try JSONEncoder().encode(next).write(to: manifest, options: .atomic)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
if let old, let oldFile = try? file(for: old) {
try? FileManager.default.removeItem(at: oldFile)
}
return next
}
func remove() throws {
if FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.removeItem(at: directory)
}
}
static func validateExport(_ data: Data, template: SavedExpenseTemplate) throws {
let result = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard result?["templateVersion"] as? String == "mapped-xlsx-v1",
result?["templateFingerprint"] as? String == template.fingerprint,
result?["templateMappingDigest"] as? String == template.mappingDigest else {
throw ExpenseTemplateFailure.message("导出未使用当前确认的模板,已停止保存,请重新导入模板后重试")
}
}
}
+274
View File
@@ -0,0 +1,274 @@
import SwiftUI
struct ExpenseTemplateSummary: View {
@EnvironmentObject var store: WorkspaceStore
@State private var targeted = false
var manage: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: targeted ? "arrow.down.doc.fill" : "doc.text")
.font(.title2).foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 4) {
Text(targeted ? "松开以导入报销单模板" : store.expenseTemplateName)
.font(.callout.weight(.medium)).lineLimit(1).help(store.expenseTemplateName)
Text(store.busy ? "处理完成后可更换模板" : "可将文件夹里的 Excel 文件直接拖到这里 · 不改变已选报销内容")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button(store.expenseTemplate == nil ? "选择模板…" : "查看 / 更换…", action: manage)
.disabled(store.busy)
}
.padding(14).background(.teal.opacity(targeted && !store.busy ? 0.12 : 0.05), in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.teal.opacity(targeted && !store.busy ? 1 : 0.2), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
.allowsHitTesting(false)
}
.dropDestination(for: URL.self) { sources, _ in
guard !store.busy else { return false }
manage()
return store.importExpenseTemplates(sources)
} isTargeted: { targeted = $0 }
}
}
struct ExpenseTemplateManager: View {
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
@State private var confirmRemove = false
@State private var targeted = false
@State private var savedFile: URL?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 5) {
Text("报销单模板").font(.title2.bold())
Text("导入本组表格,确认后即可使用。只保存本机副本,不修改原文件。")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
Button("完成") { store.discardTemplateDraft(); dismiss() }
.disabled(store.busy).keyboardShortcut(.cancelAction)
}
HStack(spacing: 14) {
Image(systemName: "square.and.arrow.down").font(.title2).foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 4) {
Text(targeted ? "松开,读取这份模板" : "把 Excel 模板拖到这里").font(.headline)
Text("从访达拖入一个 .xlsx 文件,最大 20 MB").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("选择文件…", action: store.chooseExpenseTemplate).disabled(store.busy)
}
.padding(16).frame(maxWidth: .infinity)
.background(.teal.opacity(targeted && !store.busy ? 0.14 : 0.04), in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(.teal.opacity(targeted && !store.busy ? 1 : 0.4), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
.allowsHitTesting(false)
}
.dropDestination(for: URL.self) { sources, _ in
store.importExpenseTemplates(sources)
} isTargeted: { targeted = $0 }
if store.busy {
HStack { ProgressView().controlSize(.small); Text(store.status) }.font(.callout)
}
if let message = store.templateMessage ?? store.expenseTemplateLoadError {
Label(message, systemImage: "exclamationmark.triangle")
.font(.callout).foregroundStyle(.red).textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
if let draft = store.templateDraft {
ExpenseTemplateEditor(draft: draft).id(draft.id).disabled(store.busy)
} else {
HStack(alignment: .top, spacing: 22) {
Group {
if let savedFile {
SpreadsheetPreview(url: savedFile).id(savedFile)
} else {
ContentUnavailableView("当前使用内置报销单", systemImage: "tablecells",
description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。"))
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 18) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Label("当前模板", systemImage: "checkmark.circle.fill").foregroundStyle(.teal)
Text(store.expenseTemplateName).font(.headline).textSelection(.enabled)
if let template = store.expenseTemplate {
TemplateMappingSummary(mapping: template.mapping)
Button("调整填写位置…", action: store.editExpenseTemplate)
}
Divider()
Text("导出时会自动填入").font(.headline)
Label("勾选费用,同类型合并一条", systemImage: "checkmark")
Label("已保存的收款资料", systemImage: "checkmark")
Label("用途、金额和签字岗位", systemImage: "checkmark")
Text("可在导出前预览填好内容的报销单。")
.foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading)
}
if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil {
Button("恢复内置模板…") { confirmRemove = true }
.foregroundStyle(.secondary)
}
}.font(.callout).frame(width: 270, alignment: .leading).disabled(store.busy)
}
}
}
.padding(22).frame(width: 1000, height: 700)
.interactiveDismissDisabled(store.busy || store.templateDraft != nil)
.confirmationDialog("恢复内置报销单模板?", isPresented: $confirmRemove) {
Button("恢复内置模板", role: .destructive, action: store.removeExpenseTemplate)
Button("取消", role: .cancel) {}
} message: { Text("仅移除本机模板副本,不影响本次报销数据、收款资料或原始 Excel。") }
.task(id: store.expenseTemplate?.fileName) { savedFile = store.expenseTemplateFile }
.onDisappear { if !store.busy { store.discardTemplateDraft() } }
}
}
private struct TemplateMappingSummary: View {
let mapping: ExpenseTemplateMapping
var body: some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("工作表", value: mapping.sheetName)
LabeledContent("费用明细", value: "\(mapping.startRow)\(mapping.endRow)")
LabeledContent("用途", value: mapping.columns["purpose"].map { "\($0)" } ?? "待指定")
LabeledContent("金额", value: mapping.columns["amount"].map { "\($0)" } ?? "待指定")
LabeledContent("收款人", value: mapping.cells["recipient"] ?? "未指定")
LabeledContent("开户行", value: mapping.cells["bankName"] ?? "未指定")
LabeledContent("账号", value: mapping.cells["accountNumber"] ?? "未指定")
}.font(.callout)
}
}
private struct ExpenseTemplateEditor: View {
@EnvironmentObject var store: WorkspaceStore
let draft: ExpenseTemplateDraft
@State private var mapping: ExpenseTemplateMapping
@State private var showFields = false
init(draft: ExpenseTemplateDraft) {
self.draft = draft
_mapping = State(initialValue: draft.mapping)
}
var body: some View {
HStack(alignment: .top, spacing: 22) {
VStack(alignment: .leading, spacing: 8) {
Text("原始模板预览").font(.caption).foregroundStyle(.secondary)
SpreadsheetPreview(url: draft.source).id(draft.source)
.background(.white).clipShape(RoundedRectangle(cornerRadius: 10))
Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。")
.font(.caption).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(alignment: .leading, spacing: 16) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Label("待使用的新模板", systemImage: "doc.badge.plus").foregroundStyle(.teal)
Text(draft.name).font(.headline).lineLimit(3).help(draft.name)
if draft.inspection.sheets.count > 1 {
Picker("报销工作表", selection: $mapping.sheetName) {
ForEach(draft.inspection.sheets) { Text($0.name).tag($0.name) }
}.onChange(of: mapping.sheetName) { _, name in
if let sheet = draft.inspection.sheets.first(where: { $0.name == name }) { mapping = sheet.mapping }
}
}
TemplateMappingSummary(mapping: mapping)
Button("调整填写位置…") { showFields = true }
Divider()
Text("请核对上面的填写位置。使用后,明细范围内的对应列及收款字段会被替换;未指定位置的内容会保留。")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
if mapping.columns["purpose"] == nil || mapping.columns["amount"] == nil {
Label("尚未识别完整,请先调整填写位置。", systemImage: "exclamationmark.triangle")
.font(.caption).foregroundStyle(.orange)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider()
Button("确认位置,使用此模板") { store.saveExpenseTemplate(mapping) }
.buttonStyle(.borderedProminent).controlSize(.large)
.disabled(mapping.columns["purpose", default: ""].isEmpty || mapping.columns["amount", default: ""].isEmpty)
Button("放弃更换") { store.discardTemplateDraft(); store.templateMessage = nil }
}.frame(width: 285, alignment: .leading)
}
.sheet(isPresented: $showFields) {
ExpenseTemplateFields(mapping: $mapping, inspection: draft.inspection)
}
}
}
private struct ExpenseTemplateFields: View {
@Binding var mapping: ExpenseTemplateMapping
let inspection: ExpenseTemplateInspection
@Environment(\.dismiss) private var dismiss
private let detailFields = [("purpose", "用途 *"), ("amount", "金额 *"), ("count", "单据数量"),
("invoiceType", "票据类型"), ("sequence", "序号"), ("category", "费用类别"), ("remarks", "备注(清空旧值)")]
private let cells = [("recipient", "收款人"), ("bankName", "开户行"), ("accountNumber", "账号"),
("preparer", "制单人"), ("total", "报销合计")]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("调整填写位置").font(.title2.bold())
Spacer()
Button("完成") { dismiss() }.keyboardShortcut(.defaultAction)
}
Text("仅格式识别不正确时需要调整。列填字母(如 C),单元格填地址(如 C23),合并区域填左上角。")
.font(.callout).foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("明细开始行")
TextField("9", value: $mapping.startRow, format: .number.grouping(.never)).frame(width: 70)
Text("结束行")
TextField("21", value: $mapping.endRow, format: .number.grouping(.never)).frame(width: 70)
}
Text("包括全部旧明细,不包含表头及合计;容量不足时自动复制报销工作表分页。")
.font(.caption).foregroundStyle(.secondary)
Text("明细列").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) {
ForEach(detailFields, id: \.0) { key, title in
HStack {
Text(title).frame(width: 120, alignment: .leading)
TextField("不填充", text: Binding(get: { mapping.columns[key] ?? "" },
set: { mapping.columns[key] = $0.uppercased() }))
.accessibilityLabel(title + "")
}
}
}
Text("收款与合计").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) {
ForEach(cells, id: \.0) { key, title in
HStack {
Text(title).frame(width: 120, alignment: .leading)
TextField("不填充", text: Binding(get: { mapping.cells[key] ?? "" },
set: { mapping.cells[key] = $0.uppercased() }))
.accessibilityLabel(title + "单元格")
}
}
}
LabeledContent("签字岗位") {
TextField("例如 A30,D30,A31;最多 6 项", text: $mapping.signatureCells)
}
LabeledContent("额外清空") {
TextField("例如 C27,I27;多个地址用逗号分隔", text: $mapping.clearCells)
}
Text("收款位置用本机资料替换,未保存资料则清空;签字岗位按导出弹窗中的顺序填入。留空的配置不改动模板原文。")
.font(.caption).foregroundStyle(.secondary)
DisclosureGroup("查看单元格地址与原文") {
ForEach(Array((inspection.sheets.first(where: { $0.name == mapping.sheetName })?.preview ?? []).enumerated()), id: \.offset) { _, row in
Text(row).font(.caption.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 3)
}
}
}.padding(.trailing, 8)
}
}.padding(24).frame(width: 750, height: 600).textFieldStyle(.roundedBorder)
}
}
+10 -3
View File
@@ -281,6 +281,7 @@ struct MaterialCell: View {
struct ExpenseSheet: View {
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
@State private var showTemplate = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
@@ -292,6 +293,7 @@ struct ExpenseSheet: View {
Spacer()
}
PayeeProfileSummary(profileStore: store.payeeProfile)
ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true }
ScrollView {
VStack(spacing: 12) {
ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in
@@ -332,13 +334,18 @@ struct ExpenseSheet: View {
}
Divider()
HStack {
Text("保留公司、项目和公式;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary)
Text("按当前模板填充;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary)
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button("生成所选报销单") { store.export(.expense) }.buttonStyle(.borderedProminent)
Button { store.prepareExpensePreview() } label: {
Label("预览报销单", systemImage: "doc.text.magnifyingglass")
}.buttonStyle(.borderedProminent)
.disabled(!store.expenseSelectionValid)
}
}.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder).disabled(store.busy)
}.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder)
.disabled(store.busy && !store.showExpensePreview)
.interactiveDismissDisabled(store.busy)
.sheet(isPresented: $showTemplate) { ExpenseTemplateManager() }
.sheet(isPresented: $store.showExpensePreview) { ExpensePreviewSheet() }
}
}
+52
View File
@@ -105,6 +105,58 @@ enum NativeDiagnostics {
try ExpenseSummary.validateExportResult(groupedResult, matches: groupedState.matches)
report["expenseCategoryGrouping"] = "passed"
report["groupedExpensePath"] = groupedDestination.path
let templateStorage = ExpenseTemplateStorage(root: root)
let inspectionRequest: [String: Any] = [
"operation": "inspect-expense-template", "templatePath": groupedDestination.path
]
let inspectionData = try await bridge.run(request: JSONSerialization.data(withJSONObject: inspectionRequest)) { _, _ in }
let inspection = try JSONDecoder().decode(ExpenseTemplateInspection.self, from: inspectionData)
guard let templateSheet = inspection.sheets.first else {
throw EngineFailure.message("模板识别未返回工作表")
}
let validationRequest: [String: Any] = [
"operation": "validate-expense-template", "templatePath": groupedDestination.path,
"templateMapping": try JSONSerialization.jsonObject(with: JSONEncoder().encode(templateSheet.mapping))
]
let validationData = try await bridge.run(request: JSONSerialization.data(withJSONObject: validationRequest)) { _, _ in }
let validation = try JSONDecoder().decode(ExpenseTemplateValidation.self, from: validationData)
let savedTemplate = try templateStorage.save(name: "测试模板.xlsx", source: groupedDestination, validation: validation)
let customDestination = root.appendingPathComponent("custom-template-expense.xlsx")
var customRequest = groupedRequest
customRequest["destination"] = customDestination.path
customRequest["templatePath"] = try templateStorage.file(for: savedTemplate).path
customRequest["templateMapping"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(savedTemplate.mapping))
customRequest["preparePreview"] = true
let customResult = try await bridge.run(request: JSONSerialization.data(withJSONObject: customRequest)) { _, _ in }
try ExpenseSummary.validateExportResult(customResult, matches: groupedState.matches)
try ExpenseTemplateStorage.validateExport(customResult, template: savedTemplate)
let previewMetadata = try JSONSerialization.jsonObject(with: customResult) as? [String: Any]
guard previewMetadata?["expensePreview"] as? String == "xlsx-quicklook-v1" else {
throw EngineFailure.message("应用内报销单预览未使用新版引擎")
}
let generatedData = try Data(contentsOf: customDestination)
let prepared = PreparedExpensePreview(
file: customDestination, requestDigest: try PreparedExpensePreview.digest(customRequest),
fileDigest: ExpenseTemplateStorage.fingerprint(generatedData), templateName: savedTemplate.name,
uncalculatedCells: previewMetadata?["previewUncalculatedCells"] as? [String] ?? []
)
let exactPreviewData = try prepared.verifiedData(request: customRequest)
guard exactPreviewData == generatedData else { throw EngineFailure.message("预览和导出文件不一致") }
report["expensePreviewIdentity"] = "passed"
let isolatedWorkspace = root.appendingPathComponent("workspace.json")
try JSONEncoder().encode(Workspace()).write(to: isolatedWorkspace)
let retainedTemplate = try ExpenseTemplateStorage(root: root).load()
guard retainedTemplate?.fingerprint == savedTemplate.fingerprint else {
throw EngineFailure.message("清空测试报销数据后模板未保留")
}
try templateStorage.remove()
let removedTemplate = try templateStorage.load()
guard removedTemplate == nil, FileManager.default.fileExists(atPath: groupedDestination.path) else {
throw EngineFailure.message("模板清空未隔离原始文件")
}
report["customExpenseTemplate"] = "passed"
report["templateResetIsolation"] = "passed"
report["customExpensePath"] = customDestination.path
var portraitState = Workspace()
for (index, counts) in [(1, 1), (1, 8), (3, 2)].enumerated() {
var match = state.matches[0]
+4
View File
@@ -34,6 +34,10 @@ struct ScanPage: View {
} isTargeted: { targeted in
folderTargeted = targeted
}
ExpenseTemplateSummary {
store.templateMessage = nil
store.showExpenseTemplate = true
}
if !store.state.warnings.isEmpty {
Panel {
VStack(alignment: .leading, spacing: 7) {
+238 -17
View File
@@ -20,10 +20,19 @@ final class WorkspaceStore: ObservableObject {
@Published private(set) var expenseMatchIDs: Set<String> = []
@Published var purposes: [String: String] = [:]
@Published var signatures = defaultSignatures
@Published var showExpenseTemplate = false
@Published private(set) var expenseTemplate: SavedExpenseTemplate?
@Published private(set) var expenseTemplateLoadError: String?
@Published var templateDraft: ExpenseTemplateDraft?
@Published var templateMessage: String?
@Published var showExpensePreview = false
@Published private(set) var expensePreview: PreparedExpensePreview?
@Published private(set) var expensePreviewError: String?
let payeeProfile = PayeeProfileStore()
private let engine = EngineBridge()
private let storage: URL
private var cancelRequested = false
private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) }
init() {
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
@@ -36,9 +45,139 @@ final class WorkspaceStore: ObservableObject {
} catch {
errorMessage = "无法恢复上次工作区:\(error.localizedDescription)"
}
do {
expenseTemplate = try templateStorage.load()
if let expenseTemplate { _ = try templateStorage.file(for: expenseTemplate) }
} catch {
expenseTemplateLoadError = error.localizedDescription
}
restoreMatchExplanations()
}
var expenseTemplateName: String {
expenseTemplateLoadError == nil ? (expenseTemplate?.name ?? "内置个人报销单模板") : "模板需要重新导入"
}
var expenseTemplateFile: URL? {
guard let expenseTemplate else { return nil }
return try? templateStorage.file(for: expenseTemplate)
}
@discardableResult
func importExpenseTemplates(_ sources: [URL]) -> Bool {
guard !busy else { return false }
let candidate = sources.count == 1 ? sources.first : nil
let scoped = candidate?.startAccessingSecurityScopedResource() ?? false
defer { if scoped { candidate?.stopAccessingSecurityScopedResource() } }
do {
let source = try ExpenseTemplateImportSource.validate(sources)
inspectExpenseTemplate(source, name: source.lastPathComponent)
return true
} catch {
templateMessage = error.localizedDescription
return false
}
}
func chooseExpenseTemplate() {
guard !busy else { return }
let panel = NSOpenPanel()
panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.message = "选择个人报销单 .xlsx 模板;将保存副本,不修改原始文件"
panel.prompt = "导入模板"
guard panel.runModal() == .OK, let source = panel.url else { return }
importExpenseTemplates([source])
}
func editExpenseTemplate() {
guard !busy, let expenseTemplate else { return }
do {
inspectExpenseTemplate(try templateStorage.file(for: expenseTemplate), name: expenseTemplate.name,
savedMapping: expenseTemplate.mapping)
} catch { templateMessage = error.localizedDescription }
}
private func inspectExpenseTemplate(_ source: URL, name: String, savedMapping: ExpenseTemplateMapping? = nil) {
let scoped = source.startAccessingSecurityScopedResource()
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
let candidate = storage.appendingPathComponent("template-import-\(UUID().uuidString).xlsx")
do {
let attributes = try source.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard source.pathExtension.lowercased() == "xlsx", attributes.isRegularFile == true,
(attributes.fileSize ?? 0) <= 20 * 1024 * 1024 else {
throw ExpenseTemplateFailure.message("请选择不超过 20 MB 的 .xlsx 文件")
}
try FileManager.default.copyItem(at: source, to: candidate)
} catch {
templateMessage = error.localizedDescription
return
}
busy = true
templateMessage = nil
status = "正在读取报销单模板"
progress = 0
Task {
defer { busy = false }
do {
let data = try await run(["operation": "inspect-expense-template", "templatePath": candidate.path])
let inspection = try JSONDecoder().decode(ExpenseTemplateInspection.self, from: data)
guard inspection.templateVersion == "mapped-xlsx-v1", !inspection.sheets.isEmpty else {
throw ExpenseTemplateFailure.message("无法识别模板,请检查文件或更新处理引擎")
}
discardTemplateDraft()
let suggested = inspection.sheets.first(where: { $0.name.contains("个人报销") })
?? inspection.sheets.first(where: { $0.mapping.columns["purpose"] != nil && $0.mapping.columns["amount"] != nil })
?? inspection.sheets[0]
templateDraft = ExpenseTemplateDraft(name: name, source: candidate, inspection: inspection,
mapping: savedMapping ?? suggested.mapping)
} catch {
try? FileManager.default.removeItem(at: candidate)
templateMessage = "导入未完成,原模板保持不变:\(error.localizedDescription)"
}
}
}
func saveExpenseTemplate(_ mapping: ExpenseTemplateMapping) {
guard !busy, let draft = templateDraft else { return }
busy = true
templateMessage = nil
status = "正在验证模板填充位置"
Task {
defer { busy = false }
do {
let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(mapping))
let result = try await run(["operation": "validate-expense-template", "templatePath": draft.source.path,
"templateMapping": object])
let validation = try JSONDecoder().decode(ExpenseTemplateValidation.self, from: result)
expenseTemplate = try templateStorage.save(name: draft.name, source: draft.source, validation: validation)
expenseTemplateLoadError = nil
discardTemplateDraft()
notice = "报销单模板已保存,后续个人报销单将使用“\(draft.name)"
} catch {
templateMessage = "模板尚未启用:\(error.localizedDescription)"
}
}
}
func discardTemplateDraft() {
if let templateDraft { try? FileManager.default.removeItem(at: templateDraft.source) }
templateDraft = nil
}
func removeExpenseTemplate() {
guard !busy else { return }
do {
try templateStorage.remove()
expenseTemplate = nil
expenseTemplateLoadError = nil
templateMessage = nil
discardTemplateDraft()
notice = "已恢复内置报销单模板,原始 Excel 未删除"
} catch { templateMessage = "无法移除模板:\(error.localizedDescription)" }
}
private func restoreMatchExplanations() {
guard state.matches.contains(where: { $0.matchType == "auto" && $0.explanation?.version != 2 }) else { return }
busy = true
@@ -220,17 +359,31 @@ final class WorkspaceStore: ObservableObject {
}
}
func clear() {
func clear(includeTemplate: Bool = false) {
guard !busy else { return }
let directories = materialDirectories()
if commit(Workspace()) {
resetSelection()
preview = nil
showExpense = false
showExpensePreview = false
discardExpensePreview()
expenseMatchIDs = []
purposes = [:]
directories.forEach { try? FileManager.default.removeItem(at: $0) }
notice = "工作区已清空,原始材料未删除"
if includeTemplate {
do {
try templateStorage.remove()
expenseTemplate = nil
expenseTemplateLoadError = nil
discardTemplateDraft()
} catch {
errorMessage = "本次报销数据已清空,但模板移除失败:\(error.localizedDescription)"
return
}
}
notice = includeTemplate ? "报销数据与导入的模板已清空,原始文件和收款资料未删除" :
"本次报销数据已清空,模板与收款资料已保留"
}
}
@@ -252,15 +405,91 @@ final class WorkspaceStore: ObservableObject {
} catch { errorMessage = error.localizedDescription }
}
private func expenseExportRequest() throws -> (request: [String: Any], matches: [MatchGroup], template: SavedExpenseTemplate?) {
if let expenseTemplateLoadError { throw ExpenseTemplateFailure.message(expenseTemplateLoadError) }
let selected = try MatchedPPTSelection(ids: expenseMatchIDs).exportWorkspace(from: state)
var request: [String: Any] = [
"operation": "expense", "state": try jsonObject(selected), "preparePreview": true,
"purposes": purposes, "categoryPurposes": purposes, "signatures": signatures,
"payee": try payeeProfile.exportFields()
]
if let expenseTemplate {
request["templatePath"] = try templateStorage.file(for: expenseTemplate).path
request["templateMapping"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(expenseTemplate.mapping))
}
return (request, selected.matches, expenseTemplate)
}
func discardExpensePreview() {
expensePreview?.remove()
expensePreview = nil
expensePreviewError = nil
}
func prepareExpensePreview() {
guard !busy else { return }
discardExpensePreview()
showExpensePreview = true
do {
let snapshot = try expenseExportRequest()
let name = expenseTemplateName
let digest = try PreparedExpensePreview.digest(snapshot.request)
let file = storage.appendingPathComponent("expense-preview-\(UUID().uuidString).xlsx")
busy = true
status = "正在生成报销单预览"
progress = 0
Task {
defer { busy = false }
do {
var request = snapshot.request
request["destination"] = file.path
let result = try await run(request)
try ExpenseSummary.validateExportResult(result, matches: snapshot.matches)
if let template = snapshot.template { try ExpenseTemplateStorage.validateExport(result, template: template) }
let metadata = try JSONSerialization.jsonObject(with: result) as? [String: Any]
guard metadata?["expensePreview"] as? String == "xlsx-quicklook-v1" else {
throw ExpenseTemplateFailure.message("当前引擎不支持报销单预览,请重新构建应用。")
}
expensePreview = PreparedExpensePreview(
file: file, requestDigest: digest,
fileDigest: ExpenseTemplateStorage.fingerprint(try Data(contentsOf: file)),
templateName: name, uncalculatedCells: metadata?["previewUncalculatedCells"] as? [String] ?? []
)
} catch {
try? FileManager.default.removeItem(at: file)
expensePreviewError = error is CancellationError ? "已取消生成,可返回修改或重新预览。" : error.localizedDescription
}
}
} catch { expensePreviewError = error.localizedDescription }
}
func saveExpensePreview() {
guard !busy, let prepared = expensePreview else { return }
do {
_ = try prepared.verifiedData(request: expenseExportRequest().request)
let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data]
panel.canCreateDirectories = true
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
panel.nameFieldStringValue = "个人报销单-\(formatter.string(from: Date())).xlsx"
guard panel.runModal() == .OK, let destination = panel.url else { return }
let scoped = destination.startAccessingSecurityScopedResource()
defer { if scoped { destination.stopAccessingSecurityScopedResource() } }
let data = try prepared.verifiedData(request: expenseExportRequest().request)
try data.write(to: destination, options: .atomic)
notice = "个人报销单已导出到 \(destination.lastPathComponent)"
showExpensePreview = false
NSWorkspace.shared.activateFileViewerSelecting([destination])
} catch { expensePreviewError = error.localizedDescription }
}
func export(_ kind: ExportKind, selectedMatchIDs: Set<String>? = nil) {
guard !busy, !state.matches.isEmpty else { return }
if kind == .expense { prepareExpensePreview(); return }
let exportState: Workspace
let payeeFields: [String: String]
do {
payeeFields = kind == .expense ? try payeeProfile.exportFields() : [:]
if kind == .expense {
exportState = try MatchedPPTSelection(ids: expenseMatchIDs).exportWorkspace(from: state)
} else if kind == .ppt, let selectedMatchIDs {
if kind == .ppt, let selectedMatchIDs {
exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state)
} else {
exportState = state
@@ -288,17 +517,9 @@ final class WorkspaceStore: ObservableObject {
try? FileManager.default.removeItem(at: temporary)
}
do {
var request: [String: Any] = ["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]
if kind == .expense {
request["payee"] = payeeFields
request["categoryPurposes"] = purposes
}
let result = try await run(request)
if kind == .expense {
try ExpenseSummary.validateExportResult(result, matches: exportState.matches)
}
let request: [String: Any] = ["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]
_ = try await run(request)
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
if kind == .expense { showExpense = false }
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
NSWorkspace.shared.activateFileViewerSelecting([destination])
} catch is CancellationError {
+62
View File
@@ -0,0 +1,62 @@
import Foundation
@main
struct ExpensePreviewTests {
static func rejects(_ action: () throws -> Void) {
do {
try action()
preconditionFailure("Unexpected success")
} catch {
precondition(!error.localizedDescription.isEmpty)
}
}
static func main() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let source = root.appendingPathComponent("组 模板.XLSX")
let data = Data("test worksheet bytes".utf8)
try data.write(to: source)
let accepted = try ExpenseTemplateImportSource.validate([source])
precondition(accepted == source)
rejects { _ = try ExpenseTemplateImportSource.validate([]) }
rejects { _ = try ExpenseTemplateImportSource.validate([source, source]) }
rejects { _ = try ExpenseTemplateImportSource.validate([root]) }
rejects { _ = try ExpenseTemplateImportSource.validate([URL(string: "https://example.invalid/template.xlsx")!]) }
rejects { _ = try ExpenseTemplateImportSource.validate([root.appendingPathComponent("missing.xlsx")]) }
let empty = root.appendingPathComponent("empty.xlsx")
try Data().write(to: empty)
rejects { _ = try ExpenseTemplateImportSource.validate([empty]) }
let oldFormat = root.appendingPathComponent("old.xls")
try data.write(to: oldFormat)
rejects { _ = try ExpenseTemplateImportSource.validate([oldFormat]) }
let oversized = root.appendingPathComponent("large.xlsx")
try Data(count: 20 * 1024 * 1024 + 1).write(to: oversized)
rejects { _ = try ExpenseTemplateImportSource.validate([oversized]) }
let request: [String: Any] = ["purpose": "交通", "amount": "100.30", "payee": ["recipient": "测试"]]
let preview = PreparedExpensePreview(file: source, requestDigest: try PreparedExpensePreview.digest(request),
fileDigest: ExpenseTemplateStorage.fingerprint(data), templateName: "测试",
uncalculatedCells: [])
let verified = try preview.verifiedData(request: request)
precondition(verified == data)
var changed = request
changed["destination"] = "/different/output.xlsx"
let relocated = try preview.verifiedData(request: changed)
precondition(relocated == data)
changed["payee"] = ["recipient": "新收款人"]
rejects { _ = try preview.verifiedData(request: changed) }
changed = request
changed["amount"] = "444.00"
rejects { _ = try preview.verifiedData(request: changed) }
changed = request
changed["templateMapping"] = ["purpose": "D"]
rejects { _ = try preview.verifiedData(request: changed) }
try Data("changed file".utf8).write(to: source)
rejects { _ = try preview.verifiedData(request: request) }
preview.remove()
precondition(!FileManager.default.fileExists(atPath: source.path))
rejects { _ = try preview.verifiedData(request: request) }
print("Template drop validation, preview identity, stale input and temporary cleanup tests passed")
}
}
+74
View File
@@ -0,0 +1,74 @@
import Foundation
@main
struct ExpenseTemplateTests {
static func rejects(_ action: () throws -> Void) {
do {
try action()
preconditionFailure("Unexpected success")
} catch {
precondition(!error.localizedDescription.isEmpty)
}
}
static func main() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let source = root.appendingPathComponent("原模板.xlsx")
let data = Data("Synthetic template fixture".utf8)
try data.write(to: source)
let storage = ExpenseTemplateStorage(root: root)
let initial = try storage.load()
precondition(initial == nil)
let mapping = ExpenseTemplateMapping(sheetName: "组 A", startRow: 5, endRow: 10,
columns: ["purpose": "B", "amount": "F"], cells: ["recipient": "B12"])
let validation = ExpenseTemplateValidation(templateVersion: "mapped-xlsx-v1",
fingerprint: ExpenseTemplateStorage.fingerprint(data),
mapping: mapping, mappingDigest: "test-mapping-digest")
let saved = try storage.save(name: source.lastPathComponent, source: source, validation: validation)
let reloadedStorage = ExpenseTemplateStorage(root: root)
let reloaded = try reloadedStorage.load()!
precondition(reloaded.mapping == mapping)
precondition(reloaded.name == "原模板.xlsx")
let reloadedData = try Data(contentsOf: reloadedStorage.file(for: reloaded))
precondition(reloadedData == data)
let workspace = root.appendingPathComponent("workspace.json")
try Data("current expense data".utf8).write(to: workspace)
try Data("{}".utf8).write(to: workspace, options: .atomic)
let afterWorkspaceClear = try reloadedStorage.load()
precondition(afterWorkspaceClear?.fingerprint == saved.fingerprint)
let next = try storage.save(name: "新模板.xlsx", source: source, validation: validation)
precondition(!FileManager.default.fileExists(atPath: storage.directory.appendingPathComponent(saved.fileName).path))
precondition(next.fileName != saved.fileName)
var bad = validation
bad.fingerprint = "wrong"
rejects { _ = try storage.save(name: "invalid", source: source, validation: bad) }
let afterFailedSave = try storage.load()
precondition(afterFailedSave?.fileName == next.fileName)
var traversal = next
traversal.fileName = "../原模板.xlsx"
rejects { _ = try storage.file(for: traversal) }
let result = try JSONSerialization.data(withJSONObject: [
"templateVersion": "mapped-xlsx-v1", "templateFingerprint": saved.fingerprint,
"templateMappingDigest": saved.mappingDigest
])
try ExpenseTemplateStorage.validateExport(result, template: saved)
rejects { try ExpenseTemplateStorage.validateExport(Data("{}".utf8), template: saved) }
var differentMapping = saved
differentMapping.mappingDigest = "different"
rejects { try ExpenseTemplateStorage.validateExport(result, template: differentMapping) }
let storedFile = try storage.file(for: next)
try Data("changed".utf8).write(to: storedFile)
rejects { _ = try storage.file(for: next) }
try storage.remove()
let afterRemoval = try storage.load()
precondition(afterRemoval == nil)
precondition(!FileManager.default.fileExists(atPath: storage.directory.path))
let originalData = try Data(contentsOf: source)
precondition(originalData == data)
precondition(FileManager.default.fileExists(atPath: workspace.path))
try storage.remove()
print("Template persistence, replacement, reset isolation and export fingerprint tests passed")
}
}