From 10697d3dfac8f7d44f9eea07427f2720f981842d Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Thu, 17 Sep 2026 14:13:31 +0800 Subject: [PATCH] 1 --- README.md | 24 +- native-engine/build-engine.sh | 2 + native-engine/engine.py | 19 +- native-engine/expense_preview.py | 220 ++++++++++ native-engine/expense_template.py | 440 +++++++++++++++++++ native-engine/tests/test_expense_preview.py | 118 +++++ native-engine/tests/test_expense_template.py | 288 ++++++++++++ reimburse/ContentView.swift | 13 +- reimburse/ExpensePreview.swift | 49 +++ reimburse/ExpensePreviewSheet.swift | 84 ++++ reimburse/ExpenseTemplate.swift | 132 ++++++ reimburse/ExpenseTemplateSheet.swift | 274 ++++++++++++ reimburse/MatchedPage.swift | 13 +- reimburse/NativeDiagnostics.swift | 52 +++ reimburse/ScanPage.swift | 4 + reimburse/WorkspaceStore.swift | 255 ++++++++++- tests/ExpensePreviewTests.swift | 62 +++ tests/ExpenseTemplateTests.swift | 74 ++++ 18 files changed, 2097 insertions(+), 26 deletions(-) create mode 100644 native-engine/expense_preview.py create mode 100644 native-engine/expense_template.py create mode 100644 native-engine/tests/test_expense_preview.py create mode 100644 native-engine/tests/test_expense_template.py create mode 100644 reimburse/ExpensePreview.swift create mode 100644 reimburse/ExpensePreviewSheet.swift create mode 100644 reimburse/ExpenseTemplate.swift create mode 100644 reimburse/ExpenseTemplateSheet.swift create mode 100644 tests/ExpensePreviewTests.swift create mode 100644 tests/ExpenseTemplateTests.swift diff --git a/README.md b/README.md index a82741e..f504a6b 100644 --- a/README.md +++ b/README.md @@ -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 源码。 diff --git a/native-engine/build-engine.sh b/native-engine/build-engine.sh index a14fb91..5997712 100644 --- a/native-engine/build-engine.sh +++ b/native-engine/build-engine.sh @@ -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 diff --git a/native-engine/engine.py b/native-engine/engine.py index 38cfc0c..bf996ce 100644 --- a/native-engine/engine.py +++ b/native-engine/engine.py @@ -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) diff --git a/native-engine/expense_preview.py b/native-engine/expense_preview.py new file mode 100644 index 0000000..089f0fc --- /dev/null +++ b/native-engine/expense_preview.py @@ -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) diff --git a/native-engine/expense_template.py b/native-engine/expense_template.py new file mode 100644 index 0000000..cfc44fe --- /dev/null +++ b/native-engine/expense_template.py @@ -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) diff --git a/native-engine/tests/test_expense_preview.py b/native-engine/tests/test_expense_preview.py new file mode 100644 index 0000000..a96fad2 --- /dev/null +++ b/native-engine/tests/test_expense_preview.py @@ -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() diff --git a/native-engine/tests/test_expense_template.py b/native-engine/tests/test_expense_template.py new file mode 100644 index 0000000..5052124 --- /dev/null +++ b/native-engine/tests/test_expense_template.py @@ -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() diff --git a/reimburse/ContentView.swift b/reimburse/ContentView.swift index 6ddb753..3639a41 100644 --- a/reimburse/ContentView.swift +++ b/reimburse/ContentView.swift @@ -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 { diff --git a/reimburse/ExpensePreview.swift b/reimburse/ExpensePreview.swift new file mode 100644 index 0000000..5976c21 --- /dev/null +++ b/reimburse/ExpensePreview.swift @@ -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) + } +} diff --git a/reimburse/ExpensePreviewSheet.swift b/reimburse/ExpensePreviewSheet.swift new file mode 100644 index 0000000..22a71da --- /dev/null +++ b/reimburse/ExpensePreviewSheet.swift @@ -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() } + } +} diff --git a/reimburse/ExpenseTemplate.swift b/reimburse/ExpenseTemplate.swift new file mode 100644 index 0000000..d1ea2d1 --- /dev/null +++ b/reimburse/ExpenseTemplate.swift @@ -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("导出未使用当前确认的模板,已停止保存,请重新导入模板后重试") + } + } +} diff --git a/reimburse/ExpenseTemplateSheet.swift b/reimburse/ExpenseTemplateSheet.swift new file mode 100644 index 0000000..461313f --- /dev/null +++ b/reimburse/ExpenseTemplateSheet.swift @@ -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) + } +} diff --git a/reimburse/MatchedPage.swift b/reimburse/MatchedPage.swift index dd8397a..55542ba 100644 --- a/reimburse/MatchedPage.swift +++ b/reimburse/MatchedPage.swift @@ -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() } } } diff --git a/reimburse/NativeDiagnostics.swift b/reimburse/NativeDiagnostics.swift index ee33af0..97e38a3 100644 --- a/reimburse/NativeDiagnostics.swift +++ b/reimburse/NativeDiagnostics.swift @@ -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] diff --git a/reimburse/ScanPage.swift b/reimburse/ScanPage.swift index 8f746ad..dfe0201 100644 --- a/reimburse/ScanPage.swift +++ b/reimburse/ScanPage.swift @@ -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) { diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index b33e3ca..aaff048 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -20,10 +20,19 @@ final class WorkspaceStore: ObservableObject { @Published private(set) var expenseMatchIDs: Set = [] @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? = 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 { diff --git a/tests/ExpensePreviewTests.swift b/tests/ExpensePreviewTests.swift new file mode 100644 index 0000000..bbd9d38 --- /dev/null +++ b/tests/ExpensePreviewTests.swift @@ -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") + } +} diff --git a/tests/ExpenseTemplateTests.swift b/tests/ExpenseTemplateTests.swift new file mode 100644 index 0000000..03d9b6b --- /dev/null +++ b/tests/ExpenseTemplateTests.swift @@ -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") + } +}