1
This commit is contained in:
@@ -40,6 +40,28 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。
|
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。
|
||||||
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
|
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 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。
|
已核对列表的“匹配”列会显示简要核对项,点击百分比查看每项实际得分、未得分原因及核对建议。证据评分采用真正的 **100 分制**:单张发票与付款截图的权重为金额 40 分、商户 20 分、日期 10 分、单号 15 分、批次金额唯一性 15 分;满分之和为 100,实际得分之和就是百分比,各项未得分之和就是差额。例如仅金额一致且批次唯一时为 55/100 分,即 55%。日期相差不超过 3 天得 10 分,4–7 天得 5 分;商户达到 35% 相似度后按相似度 × 20 分四舍五入。其他组合规则的满分原本就是 100 分,仍保留;多笔付款固定 95 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。
|
||||||
|
|
||||||
旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore`(`version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。
|
旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore`(`version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。
|
||||||
@@ -67,7 +89,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
|
|
||||||
保留一对一评分、多发票合计、多付款合计、同程多人归组、歧义判断、七类关键词分类、人工多对多、撤销、预览、金额统计和三类 Office 文件导出。
|
保留一对一评分、多发票合计、多付款合计、同程多人归组、歧义判断、七类关键词分类、人工多对多、撤销、预览、金额统计和三类 Office 文件导出。
|
||||||
|
|
||||||
个人报销单只使用打开弹窗时勾选的材料,按费用类型合并明细。例如勾选 6 组交通,只生成一条“交通”,金额和发票数量累计;未勾选材料不计入。弹窗显示合并后的类型、组数、单据数和金额,可按类型填写用途。金额沿用原报销单口径(每张发票 OCR 金额的绝对值最大值),不改为付款金额;混合票据类型会一并列明。按首次出现的类型顺序输出,每页最多 13 个类型,收款信息与模板公式保持不变。
|
个人报销单只使用打开弹窗时勾选的材料,按费用类型合并明细。例如勾选 6 组交通,只生成一条“交通”,金额和发票数量累计;未勾选材料不计入。弹窗显示合并后的类型、组数、单据数和金额,可按类型填写用途。金额沿用原报销单口径(每张发票 OCR 金额的绝对值最大值),不改为付款金额;混合票据类型会一并列明。按首次出现的类型顺序输出,内置模板每页最多 13 个类型,自定义模板按确认的明细范围分页。
|
||||||
|
|
||||||
保存个人报销单前还会核对引擎返回的分类合并版本和明细行数;旧引擎或行数与预览不一致时停止保存,不覆盖用户选定的输出文件。打包引擎通过 `RECEIPT_ENGINE_BINARY` 运行 `test_exports.py` 的 `engine_process` 测试。最终应用的引擎带沙盒继承签名,不能直接从普通终端启动;应使用 Debug 应用的 `--verify-local-engine` 自检入口,从真实应用内验证两条汇总并检查生成的 `grouped-expense.xlsx`,而不能仅验证 Python 源码。
|
保存个人报销单前还会核对引擎返回的分类合并版本和明细行数;旧引擎或行数与预览不一致时停止保存,不覆盖用户选定的输出文件。打包引擎通过 `RECEIPT_ENGINE_BINARY` 运行 `test_exports.py` 的 `engine_process` 测试。最终应用的引擎带沙盒继承签名,不能直接从普通终端启动;应使用 Debug 应用的 `--verify-local-engine` 自检入口,从真实应用内验证两条汇总并检查生成的 `grouped-expense.xlsx`,而不能仅验证 Python 源码。
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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
|
if [ "$SOURCE_HASH" != "$(bash "$ROOT/native-engine/engine-fingerprint.sh")" ]; then
|
||||||
echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2
|
echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from domain import auto_match, enrich, extract
|
from domain import auto_match, enrich, extract
|
||||||
from exports import export_expense, export_ppt, export_travel
|
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
|
PROTOCOL = sys.stdout
|
||||||
SUPPORTED = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif', '.pdf'}
|
SUPPORTED = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif', '.pdf'}
|
||||||
@@ -83,6 +85,10 @@ def dispatch(request):
|
|||||||
operation = request['operation']
|
operation = request['operation']
|
||||||
if operation == 'scan':
|
if operation == 'scan':
|
||||||
return scan(request)
|
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']
|
state = request['state']
|
||||||
if operation == 'refresh':
|
if operation == 'refresh':
|
||||||
return enrich(state)
|
return enrich(state)
|
||||||
@@ -97,9 +103,16 @@ def dispatch(request):
|
|||||||
elif operation == 'travel':
|
elif operation == 'travel':
|
||||||
export_travel(state, temporary)
|
export_travel(state, temporary)
|
||||||
elif operation == 'expense':
|
elif operation == 'expense':
|
||||||
|
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'
|
template = Path(__file__).parent / 'personal-expense-template.xlsx'
|
||||||
metadata = export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []),
|
metadata = export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []),
|
||||||
request.get('payee', {}), request.get('categoryPurposes'))
|
request.get('payee', {}), request.get('categoryPurposes'))
|
||||||
|
if request.get('preparePreview'):
|
||||||
|
metadata.update(prepare_preview(temporary))
|
||||||
else:
|
else:
|
||||||
raise ValueError('未知操作:' + operation)
|
raise ValueError('未知操作:' + operation)
|
||||||
os.replace(temporary, destination)
|
os.replace(temporary, destination)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import test_expense_template as fixtures
|
||||||
|
from expense_preview import PreviewCalculator, UnsupportedFormula, builtin_rmb, prepare_preview
|
||||||
|
from expense_template import Template, encoded, set_cell, tag
|
||||||
|
|
||||||
|
|
||||||
|
class ExpensePreviewTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.fixture = fixtures.ExpenseTemplateTests()
|
||||||
|
self.fixture.setUp()
|
||||||
|
self.addCleanup(self.fixture.tearDown)
|
||||||
|
|
||||||
|
def test_filled_workbook_previews_current_totals_and_preserves_formulas(self):
|
||||||
|
output, _ = self.fixture.export()
|
||||||
|
result = prepare_preview(output)
|
||||||
|
values = load_workbook(output, data_only=True)
|
||||||
|
formulas = load_workbook(output)
|
||||||
|
sheet = values.active
|
||||||
|
self.assertEqual(sheet['C9'].value, '交通')
|
||||||
|
self.assertEqual(sheet['H9'].value, 100.3)
|
||||||
|
self.assertEqual(sheet['H10'].value, 444)
|
||||||
|
self.assertEqual(sheet['F27'].value, 544.3)
|
||||||
|
self.assertEqual(sheet['H29'].value, 544.3)
|
||||||
|
self.assertEqual(formulas.active['F27'].value, '=SUM(H9:H21)')
|
||||||
|
self.assertEqual(formulas.active['H29'].value, '=IF(F27>C27,F27-C27,0)')
|
||||||
|
self.assertEqual(result['previewUncalculatedCells'], [])
|
||||||
|
self.assertEqual(sheet['D29'].value, '伍佰肆拾肆元叁角整')
|
||||||
|
self.assertEqual(result['expensePreview'], 'xlsx-quicklook-v1')
|
||||||
|
values.close()
|
||||||
|
formulas.close()
|
||||||
|
|
||||||
|
def test_builtin_uppercase_amount_formula(self):
|
||||||
|
for amount, expected in [('3491.86', '叁仟肆佰玖拾壹元捌角陆分'), ('10001.01', '壹万零壹元零壹分'),
|
||||||
|
('100000001', '壹亿零壹元整'), ('100010000', '壹亿零壹万元整'),
|
||||||
|
('0.01', '壹分'), ('0.1', '壹角整'), ('-50.02', '负伍拾元零贰分'),
|
||||||
|
('0', ''), ('1.999', '贰元整'), ('999999999999.99', '玖仟玖佰玖拾玖亿玖仟玖佰玖拾玖万玖仟玖佰玖拾玖元玖角玖分')]:
|
||||||
|
with self.subTest(amount=amount):
|
||||||
|
self.assertEqual(builtin_rmb(Decimal(amount)), expected)
|
||||||
|
def test_caches_each_custom_page_not_whole_report(self):
|
||||||
|
source, mapping = self.fixture.alternate()
|
||||||
|
state = dict(matches=self.fixture.state['matches'] + [self.fixture.match('third', '住宿', '50')])
|
||||||
|
output, _ = self.fixture.export(mapping=mapping, template=source, state=state)
|
||||||
|
prepare_preview(output)
|
||||||
|
workbook = load_workbook(output, data_only=True)
|
||||||
|
self.assertEqual(workbook['组 B 报销']['F8'].value, 544.3)
|
||||||
|
self.assertEqual(workbook['组 B 报销-续2']['F8'].value, 50)
|
||||||
|
self.assertIn('保留说明', workbook.sheetnames)
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
def test_clears_unknown_cache_and_does_not_execute_formula(self):
|
||||||
|
output, _ = self.fixture.export()
|
||||||
|
template = Template(output)
|
||||||
|
_, path, document = template.sheet('个人报销单')
|
||||||
|
set_cell(document, 'I35', '__import__("os").system("touch /tmp/should-not-run")', formula=True)
|
||||||
|
from lxml import etree as ET
|
||||||
|
ET.SubElement(document.find('.//' + tag('c') + '[@r="I35"]'), tag('v')).text = '9999'
|
||||||
|
template.parts[path] = encoded(document)
|
||||||
|
self.fixture.rewrite(output, template.parts)
|
||||||
|
result = prepare_preview(output)
|
||||||
|
self.assertIn('个人报销单!I35', result['previewUncalculatedCells'])
|
||||||
|
workbook = load_workbook(output, data_only=True)
|
||||||
|
self.assertIsNone(workbook.active['I35'].value)
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
def test_calculator_cycles_text_dates_and_conditionals(self):
|
||||||
|
template = Template(self.fixture.template)
|
||||||
|
_, _, sheet = template.sheet('个人报销单')
|
||||||
|
set_cell(sheet, 'J1', 10)
|
||||||
|
set_cell(sheet, 'J2', '文字')
|
||||||
|
set_cell(sheet, 'J3', '-J1*2+5', formula=True)
|
||||||
|
set_cell(sheet, 'J4', 'J5', formula=True)
|
||||||
|
set_cell(sheet, 'J5', 'J4', formula=True)
|
||||||
|
calculator = PreviewCalculator(sheet)
|
||||||
|
self.assertEqual(calculator.cell('J3'), -15)
|
||||||
|
self.assertEqual(calculator.expression('SUM(J1:J3)'), -5)
|
||||||
|
self.assertEqual(calculator.expression('IF(J1>0,J1/2,1/0)'), 5)
|
||||||
|
self.assertEqual(calculator.expression('TODAY()'), (datetime.date.today() - datetime.date(1899, 12, 30)).days)
|
||||||
|
self.assertEqual(PreviewCalculator(sheet, True).expression('TODAY()'),
|
||||||
|
(datetime.date.today() - datetime.date(1904, 1, 1)).days)
|
||||||
|
with self.assertRaises(UnsupportedFormula):
|
||||||
|
calculator.cell('J4')
|
||||||
|
with self.assertRaises(UnsupportedFormula):
|
||||||
|
calculator.expression('2**10000000')
|
||||||
|
|
||||||
|
def test_engine_process_preview_builtin_and_imported_templates(self):
|
||||||
|
for custom in [False, True]:
|
||||||
|
with self.subTest(custom=custom):
|
||||||
|
output = self.fixture.root / ('custom-preview.xlsx' if custom else 'builtin-preview.xlsx')
|
||||||
|
request = dict(operation='expense', state=self.fixture.state, destination=str(output), preparePreview=True)
|
||||||
|
if custom:
|
||||||
|
request.update(templatePath=str(self.fixture.template), templateMapping=self.fixture.mapping)
|
||||||
|
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||||||
|
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||||||
|
process = subprocess.run(command, input=json.dumps(request) + '\n', text=True, capture_output=True, timeout=60)
|
||||||
|
self.assertEqual(process.returncode, 0, process.stderr + process.stdout)
|
||||||
|
result = next(json.loads(line)['result'] for line in process.stdout.splitlines() if json.loads(line).get('event') == 'result')
|
||||||
|
self.assertEqual(result['expensePreview'], 'xlsx-quicklook-v1')
|
||||||
|
self.assertEqual(result['expenseRowCount'], 2)
|
||||||
|
workbook = load_workbook(output, data_only=True)
|
||||||
|
self.assertEqual(workbook.active['F27'].value, 544.3)
|
||||||
|
self.assertEqual(workbook.active['H29'].value, 544.3)
|
||||||
|
self.assertIn(workbook.active['C23'].value, ('', None))
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lxml import etree as ET
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from expense_template import (CONTENT, MAIN, PACKAGE, REL, Template, encoded, export_custom_expense,
|
||||||
|
inspect_template, set_cell, tag, validate_template, xml)
|
||||||
|
from exports import export_expense
|
||||||
|
from test_domain import material, workspace
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseTemplateTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.builtin = Path(__file__).resolve().parents[1] / 'personal-expense-template.xlsx'
|
||||||
|
self.state = workspace([], [])
|
||||||
|
self.state['matches'] = [self.match('traffic-1', '交通', '30.10'), self.match('traffic-2', '交通', '70.20'),
|
||||||
|
self.match('office', '办公用品', '444.00')]
|
||||||
|
self.template = self.root / '旧报销单.xlsx'
|
||||||
|
old = dict(matches=[self.match('old-' + str(index), '旧类别' + str(index), '999.00') for index in range(7)])
|
||||||
|
export_expense(old, self.template, self.builtin, {}, ['旧岗位'],
|
||||||
|
dict(recipient='旧收款人', bankName='旧开户行', accountNumber='9999999', preparer='旧制单人'))
|
||||||
|
self.mapping = inspect_template(self.template)['sheets'][0]['mapping']
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def match(self, identifier, category, amount):
|
||||||
|
return dict(id=identifier, category=category, invoices=[material(identifier, 'invoice', amount)], payments=[])
|
||||||
|
|
||||||
|
def export(self, mapping=None, template=None, state=None, payee=None):
|
||||||
|
output = self.root / 'output.xlsx'
|
||||||
|
metadata = export_custom_expense(state or self.state, output, template or self.template, mapping or self.mapping,
|
||||||
|
{}, ['经办人', '财务'], payee or {},
|
||||||
|
{'交通': '交通', '办公用品': '=办公用品'})
|
||||||
|
return output, metadata
|
||||||
|
|
||||||
|
def rewrite(self, path, parts):
|
||||||
|
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for name, data in parts.items():
|
||||||
|
archive.writestr(name, data)
|
||||||
|
|
||||||
|
def alternate(self):
|
||||||
|
template = Template(self.template)
|
||||||
|
sheet, path, _ = template.sheet('个人报销单')
|
||||||
|
sheet.set('name', '组 B 报销')
|
||||||
|
document = ET.Element(tag('worksheet'), nsmap={None: MAIN})
|
||||||
|
ET.SubElement(document, tag('sheetData'))
|
||||||
|
values = {'A1': '自定义组 B 报销单', 'A4': '序号', 'B4': '用途', 'D4': '单据数量', 'F4': '金额',
|
||||||
|
'G4': '备注', 'B5': '旧内容1', 'F5': 500, 'G5': '旧备注', 'B6': '旧内容2', 'F6': 800,
|
||||||
|
'A10': '收款人', 'B10': '旧收款人', 'A11': '开户行', 'B11': '旧银行',
|
||||||
|
'A12': '账号', 'B12': '旧账号', 'E2': '制单人', 'F2': '旧人', 'E8': '报销合计',
|
||||||
|
'A14': '要清空的旧值', 'A15': '旧签字', 'F15': '旧岗位', 'A16': '保留的自定义说明'}
|
||||||
|
for reference, value in values.items():
|
||||||
|
set_cell(document, reference, value)
|
||||||
|
set_cell(document, 'F8', 'SUM(F5:F6)', formula=True)
|
||||||
|
merges = ET.SubElement(document, tag('mergeCells'), count='3')
|
||||||
|
for reference in ['B4:C4', 'B5:C5', 'B6:C6']:
|
||||||
|
ET.SubElement(merges, tag('mergeCell'), ref=reference)
|
||||||
|
ET.SubElement(document, tag('pageMargins'), left='0.2', right='0.2', top='0.3', bottom='0.3', header='0', footer='0')
|
||||||
|
ET.SubElement(document, tag('pageSetup'), paperSize='9', orientation='landscape', fitToWidth='1')
|
||||||
|
template.parts[path] = encoded(document)
|
||||||
|
names = template.workbook.find(tag('definedNames'))
|
||||||
|
for item in list(names):
|
||||||
|
names.remove(item)
|
||||||
|
ET.SubElement(names, tag('definedName'), name='_xlnm.Print_Area', localSheetId='0').text = "'组 B 报销'!$A$1:$G$16"
|
||||||
|
sheet_list = template.workbook.find(tag('sheets'))
|
||||||
|
ET.SubElement(sheet_list, tag('sheet'), name='保留说明', sheetId='2', attrib={'{' + REL + '}id': 'rIdNotes'})
|
||||||
|
ET.SubElement(template.relations, '{' + PACKAGE + '}Relationship', Id='rIdNotes', Type=REL + '/worksheet',
|
||||||
|
Target='worksheets/notes.xml')
|
||||||
|
ET.SubElement(template.types, '{' + CONTENT + '}Override', PartName='/xl/worksheets/notes.xml',
|
||||||
|
ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
|
||||||
|
notes = ET.Element(tag('worksheet'), nsmap={None: MAIN})
|
||||||
|
ET.SubElement(notes, tag('sheetData'))
|
||||||
|
set_cell(notes, 'A1', '保持这个工作表不变')
|
||||||
|
template.parts['xl/worksheets/notes.xml'] = encoded(notes)
|
||||||
|
template.parts['xl/workbook.xml'] = encoded(template.workbook)
|
||||||
|
template.parts['xl/_rels/workbook.xml.rels'] = encoded(template.relations)
|
||||||
|
template.parts['[Content_Types].xml'] = encoded(template.types)
|
||||||
|
target = self.root / 'different-layout.xlsx'
|
||||||
|
self.rewrite(target, template.parts)
|
||||||
|
mapping = inspect_template(target)['sheets'][0]['mapping']
|
||||||
|
mapping['signatureCells'] = 'A15,F15'
|
||||||
|
mapping['clearCells'] = 'A14'
|
||||||
|
return target, mapping
|
||||||
|
|
||||||
|
def test_auto_detect_uploaded_style_and_merged_signature_slots(self):
|
||||||
|
self.assertEqual(self.mapping['sheetName'], '个人报销单')
|
||||||
|
self.assertEqual((self.mapping['startRow'], self.mapping['endRow']), (9, 21))
|
||||||
|
self.assertEqual(self.mapping['columns'], dict(purpose='C', amount='H', count='G', invoiceType='B', sequence='A', remarks='I'))
|
||||||
|
self.assertEqual(self.mapping['cells'], dict(recipient='C23', bankName='C24', accountNumber='C25', preparer='H6', total='F27'))
|
||||||
|
self.assertNotIn('D32', self.mapping['signatureCells'])
|
||||||
|
validation = validate_template(self.template, self.mapping)
|
||||||
|
self.assertEqual(validation['mapping'], self.mapping)
|
||||||
|
self.assertEqual(len(validation['fingerprint']), 64)
|
||||||
|
|
||||||
|
def test_replaces_old_seven_rows_with_two_aggregated_categories(self):
|
||||||
|
original = self.template.read_bytes()
|
||||||
|
output, metadata = self.export()
|
||||||
|
self.assertEqual(metadata['expenseRowCount'], 2)
|
||||||
|
self.assertEqual(metadata['expensePageCount'], 1)
|
||||||
|
workbook = load_workbook(output)
|
||||||
|
sheet = workbook.active
|
||||||
|
self.assertEqual(sheet['C9'].value, '交通')
|
||||||
|
self.assertEqual(sheet['H9'].value, 100.30)
|
||||||
|
self.assertEqual(sheet['G9'].value, 2)
|
||||||
|
self.assertEqual(sheet['C10'].value, '=办公用品')
|
||||||
|
self.assertEqual(sheet['C10'].data_type, 's')
|
||||||
|
self.assertEqual(sheet['H10'].value, 444)
|
||||||
|
for row in range(11, 22):
|
||||||
|
for column in ['A', 'B', 'C', 'G', 'H', 'I']:
|
||||||
|
self.assertIn(sheet[f'{column}{row}'].value, ('', None))
|
||||||
|
for reference in ['C23', 'C24', 'C25', 'H6']:
|
||||||
|
self.assertIn(sheet[reference].value, ('', None))
|
||||||
|
self.assertEqual(sheet['F27'].value, '=SUM(H9:H21)')
|
||||||
|
self.assertEqual(sheet['A30'].value, '经办人:')
|
||||||
|
self.assertEqual(sheet['D30'].value, '财务:')
|
||||||
|
self.assertEqual(self.template.read_bytes(), original)
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
def test_preserves_styles_merges_print_settings_and_media(self):
|
||||||
|
output, _ = self.export()
|
||||||
|
with zipfile.ZipFile(self.template) as original, zipfile.ZipFile(output) as generated:
|
||||||
|
for name in original.namelist():
|
||||||
|
if name == 'xl/styles.xml' or name.startswith('xl/media/') or name.startswith('xl/drawings/'):
|
||||||
|
self.assertEqual(original.read(name), generated.read(name), name)
|
||||||
|
old, new = load_workbook(self.template), load_workbook(output)
|
||||||
|
for reference in ['A1', 'A3', 'A4', 'C9', 'H9', 'C25']:
|
||||||
|
self.assertEqual(old.active[reference].style_id, new.active[reference].style_id)
|
||||||
|
self.assertEqual(str(old.active.merged_cells), str(new.active.merged_cells))
|
||||||
|
self.assertEqual(str(old.active.print_area), str(new.active.print_area))
|
||||||
|
self.assertEqual(old.active.page_setup, new.active.page_setup)
|
||||||
|
self.assertEqual(old.active['A3'].value, new.active['A3'].value)
|
||||||
|
self.assertEqual(old.active['A4'].value, new.active['A4'].value)
|
||||||
|
self.assertEqual(old.active['D29'].value, new.active['D29'].value)
|
||||||
|
old.close()
|
||||||
|
new.close()
|
||||||
|
|
||||||
|
def test_alternate_layout_preserves_other_sheet_and_paginates(self):
|
||||||
|
template, mapping = self.alternate()
|
||||||
|
self.assertEqual(mapping['columns']['purpose'], 'B')
|
||||||
|
self.assertEqual((mapping['startRow'], mapping['endRow']), (5, 6))
|
||||||
|
self.assertEqual(mapping['cells']['total'], 'F8')
|
||||||
|
state = dict(matches=self.state['matches'] + [self.match('hotel', '住宿', '9.99')])
|
||||||
|
output, result = self.export(mapping, template, state,
|
||||||
|
dict(recipient='新收款人', bankName='=新银行', accountNumber='0001234567890123456789'))
|
||||||
|
self.assertEqual(result['expensePageCount'], 2)
|
||||||
|
workbook = load_workbook(output)
|
||||||
|
self.assertEqual(workbook.sheetnames, ['组 B 报销', '保留说明', '组 B 报销-续2'])
|
||||||
|
self.assertEqual(workbook['保留说明']['A1'].value, '保持这个工作表不变')
|
||||||
|
for sheet in [workbook['组 B 报销'], workbook['组 B 报销-续2']]:
|
||||||
|
self.assertEqual(sheet['B10'].value, '新收款人')
|
||||||
|
self.assertEqual(sheet['F2'].value, '新收款人')
|
||||||
|
self.assertEqual(sheet['B11'].data_type, 's')
|
||||||
|
self.assertEqual(sheet['B12'].value, '0001234567890123456789')
|
||||||
|
self.assertEqual(sheet['B12'].data_type, 's')
|
||||||
|
self.assertEqual(sheet['F8'].value, '=SUM(F5:F6)')
|
||||||
|
self.assertEqual(sheet['A16'].value, '保留的自定义说明')
|
||||||
|
self.assertIn(sheet['A14'].value, ('', None))
|
||||||
|
self.assertEqual(sheet['A15'].value, '经办人:')
|
||||||
|
self.assertEqual(sheet.page_setup.orientation, 'landscape')
|
||||||
|
self.assertTrue(str(sheet.print_area).endswith('!$A$1:$G$16'))
|
||||||
|
self.assertEqual(workbook['组 B 报销-续2']['B5'].value, '住宿')
|
||||||
|
self.assertEqual(workbook['组 B 报销-续2']['A5'].value, 3)
|
||||||
|
self.assertIn(workbook['组 B 报销-续2']['B6'].value, ('', None))
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
def test_unmapped_signature_cells_preserve_template(self):
|
||||||
|
mapping = copy.deepcopy(self.mapping)
|
||||||
|
mapping['signatureCells'] = ''
|
||||||
|
output, _ = self.export(mapping)
|
||||||
|
book = load_workbook(output)
|
||||||
|
self.assertEqual(book.active['A30'].value, '旧岗位:')
|
||||||
|
book.close()
|
||||||
|
|
||||||
|
def test_mapping_normalizes_lowercase(self):
|
||||||
|
mapping = copy.deepcopy(self.mapping)
|
||||||
|
mapping['columns']['purpose'] = ' c '
|
||||||
|
mapping['cells']['recipient'] = ' c23 '
|
||||||
|
mapping['signatureCells'] = 'a30, d30'
|
||||||
|
result = validate_template(self.template, mapping)
|
||||||
|
self.assertEqual(result['mapping']['columns']['purpose'], 'C')
|
||||||
|
self.assertEqual(result['mapping']['cells']['recipient'], 'C23')
|
||||||
|
self.assertEqual(result['mapping']['signatureCells'], 'A30,D30')
|
||||||
|
|
||||||
|
def test_invalid_mappings_do_not_write_output(self):
|
||||||
|
mutations = [
|
||||||
|
lambda mapping: mapping.update(startRow=22, endRow=9),
|
||||||
|
lambda mapping: mapping.update(startRow=0),
|
||||||
|
lambda mapping: mapping.update(endRow=300),
|
||||||
|
lambda mapping: mapping.update(sheetName='不存在'),
|
||||||
|
lambda mapping: mapping['columns'].update(purpose='D'),
|
||||||
|
lambda mapping: mapping['columns'].update(count='H'),
|
||||||
|
lambda mapping: mapping['columns'].update(amount=''),
|
||||||
|
lambda mapping: mapping['columns'].update(amount='A1'),
|
||||||
|
lambda mapping: mapping['cells'].update(recipient='C9'),
|
||||||
|
lambda mapping: mapping['cells'].update(bankName='C23'),
|
||||||
|
lambda mapping: mapping.update(signatureCells='D32'),
|
||||||
|
lambda mapping: mapping.update(clearCells='D23'),
|
||||||
|
lambda mapping: mapping.update(clearCells='C9'),
|
||||||
|
]
|
||||||
|
for mutate in mutations:
|
||||||
|
mapping = copy.deepcopy(self.mapping)
|
||||||
|
mutate(mapping)
|
||||||
|
with self.subTest(mapping=mapping), self.assertRaises(ValueError):
|
||||||
|
self.export(mapping)
|
||||||
|
self.assertFalse((self.root / 'output.xlsx').exists())
|
||||||
|
|
||||||
|
def test_corrupt_and_non_xlsx_rejected(self):
|
||||||
|
for name in ['bad.xlsx', 'old.xls', 'macro.xlsm']:
|
||||||
|
path = self.root / name
|
||||||
|
path.write_bytes(b'not an xlsx')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
inspect_template(path)
|
||||||
|
|
||||||
|
def test_live_external_formula_rejected_but_orphans_removed(self):
|
||||||
|
template = Template(self.template)
|
||||||
|
_, path, document = template.sheet('个人报销单')
|
||||||
|
set_cell(document, 'J1', "'[1]Sheet1'!A1", formula=True)
|
||||||
|
template.parts[path] = encoded(document)
|
||||||
|
live = self.root / 'external.xlsx'
|
||||||
|
self.rewrite(live, template.parts)
|
||||||
|
with self.assertRaisesRegex(ValueError, '外部'):
|
||||||
|
inspect_template(live)
|
||||||
|
output, _ = self.export()
|
||||||
|
with zipfile.ZipFile(output) as archive:
|
||||||
|
self.assertFalse(any(name.startswith('xl/externalLinks/') for name in archive.namelist()))
|
||||||
|
|
||||||
|
def test_formula_caches_are_invalidated(self):
|
||||||
|
output, _ = self.export()
|
||||||
|
template = Template(output)
|
||||||
|
_, _, document = template.sheet('个人报销单')
|
||||||
|
self.assertTrue(list(document.iter(tag('f'))))
|
||||||
|
for cell in document.iter(tag('c')):
|
||||||
|
if cell.find(tag('f')) is not None:
|
||||||
|
self.assertIsNone(cell.find(tag('v')))
|
||||||
|
self.assertEqual(template.workbook.find(tag('calcPr')).get('calcMode'), 'auto')
|
||||||
|
self.assertNotIn('xl/calcChain.xml', template.parts)
|
||||||
|
|
||||||
|
def engine(self, request):
|
||||||
|
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||||||
|
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||||||
|
process = subprocess.run(command, input=json.dumps(request), capture_output=True, text=True, timeout=60)
|
||||||
|
return process, json.loads(process.stdout.splitlines()[-1])
|
||||||
|
|
||||||
|
def test_engine_process_inspects_validates_and_uses_custom_template(self):
|
||||||
|
process, inspected = self.engine(dict(operation='inspect-expense-template', templatePath=str(self.template)))
|
||||||
|
self.assertEqual(process.returncode, 0, process.stderr)
|
||||||
|
self.assertEqual(inspected['result']['sheets'][0]['mapping'], self.mapping)
|
||||||
|
process, validated = self.engine(dict(operation='validate-expense-template', templatePath=str(self.template), templateMapping=self.mapping))
|
||||||
|
self.assertEqual(process.returncode, 0, process.stderr)
|
||||||
|
output = self.root / 'protocol.xlsx'
|
||||||
|
process, result = self.engine(dict(operation='expense', state=self.state, destination=str(output),
|
||||||
|
templatePath=str(self.template), templateMapping=self.mapping,
|
||||||
|
payee=dict(recipient='新用户', accountNumber='0009876543210987654321')))
|
||||||
|
self.assertEqual(process.returncode, 0, process.stderr)
|
||||||
|
self.assertEqual(result['result']['templateFingerprint'], hashlib.sha256(self.template.read_bytes()).hexdigest())
|
||||||
|
self.assertEqual(result['result']['templateMappingDigest'], validated['result']['mappingDigest'])
|
||||||
|
self.assertEqual(result['result']['expenseRowCount'], 2)
|
||||||
|
book = load_workbook(output)
|
||||||
|
self.assertEqual(book.active['C23'].value, '新用户')
|
||||||
|
self.assertEqual(book.active['H9'].value, 100.30)
|
||||||
|
book.close()
|
||||||
|
|
||||||
|
def test_engine_process_missing_custom_template_never_falls_back(self):
|
||||||
|
output = self.root / 'existing.xlsx'
|
||||||
|
output.write_bytes(b'keep existing output')
|
||||||
|
process, result = self.engine(dict(operation='expense', state=self.state, destination=str(output),
|
||||||
|
templatePath=str(self.root / 'missing.xlsx'), templateMapping=self.mapping))
|
||||||
|
self.assertNotEqual(process.returncode, 0)
|
||||||
|
self.assertEqual(result['event'], 'error')
|
||||||
|
self.assertEqual(output.read_bytes(), b'keep existing output')
|
||||||
|
self.assertFalse(list(self.root.glob('.*.xlsx')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -69,6 +69,11 @@ struct ContentView: View {
|
|||||||
.tint(.teal)
|
.tint(.teal)
|
||||||
.frame(minWidth: 1050, minHeight: 700)
|
.frame(minWidth: 1050, minHeight: 700)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: {
|
||||||
|
Label("报销模板", systemImage: "doc.text")
|
||||||
|
}.disabled(store.busy)
|
||||||
|
}
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button { showPayeeProfile = true } label: {
|
Button { showPayeeProfile = true } label: {
|
||||||
Label("收款信息", systemImage: "person.crop.rectangle")
|
Label("收款信息", systemImage: "person.crop.rectangle")
|
||||||
@@ -83,12 +88,16 @@ struct ContentView: View {
|
|||||||
Button("知道了") { store.errorMessage = nil }
|
Button("知道了") { store.errorMessage = nil }
|
||||||
} message: { Text(store.errorMessage ?? "") }
|
} message: { Text(store.errorMessage ?? "") }
|
||||||
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
|
.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) {}
|
Button("取消", role: .cancel) {}
|
||||||
} message: { Text("当前工作区的副本和核对结果将被移除,您选择的原始文件夹不会被修改。") }
|
} message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除。") }
|
||||||
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
|
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
|
||||||
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
|
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
|
||||||
.sheet(isPresented: $showPayeeProfile) { PayeeProfileSheet(profileStore: store.payeeProfile) }
|
.sheet(isPresented: $showPayeeProfile) { PayeeProfileSheet(profileStore: store.payeeProfile) }
|
||||||
|
.sheet(isPresented: $store.showExpenseTemplate) { ExpenseTemplateManager() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topbar: some View {
|
private var topbar: some View {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("导出未使用当前确认的模板,已停止保存,请重新导入模板后重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,6 +281,7 @@ struct MaterialCell: View {
|
|||||||
struct ExpenseSheet: View {
|
struct ExpenseSheet: View {
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showTemplate = false
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -292,6 +293,7 @@ struct ExpenseSheet: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
PayeeProfileSummary(profileStore: store.payeeProfile)
|
PayeeProfileSummary(profileStore: store.payeeProfile)
|
||||||
|
ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true }
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in
|
ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in
|
||||||
@@ -332,13 +334,18 @@ struct ExpenseSheet: View {
|
|||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
HStack {
|
HStack {
|
||||||
Text("保留公司、项目和公式;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary)
|
Text("按当前模板填充;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
|
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
Button("生成所选报销单") { store.export(.expense) }.buttonStyle(.borderedProminent)
|
Button { store.prepareExpensePreview() } label: {
|
||||||
|
Label("预览报销单", systemImage: "doc.text.magnifyingglass")
|
||||||
|
}.buttonStyle(.borderedProminent)
|
||||||
.disabled(!store.expenseSelectionValid)
|
.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)
|
.interactiveDismissDisabled(store.busy)
|
||||||
|
.sheet(isPresented: $showTemplate) { ExpenseTemplateManager() }
|
||||||
|
.sheet(isPresented: $store.showExpensePreview) { ExpensePreviewSheet() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,58 @@ enum NativeDiagnostics {
|
|||||||
try ExpenseSummary.validateExportResult(groupedResult, matches: groupedState.matches)
|
try ExpenseSummary.validateExportResult(groupedResult, matches: groupedState.matches)
|
||||||
report["expenseCategoryGrouping"] = "passed"
|
report["expenseCategoryGrouping"] = "passed"
|
||||||
report["groupedExpensePath"] = groupedDestination.path
|
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()
|
var portraitState = Workspace()
|
||||||
for (index, counts) in [(1, 1), (1, 8), (3, 2)].enumerated() {
|
for (index, counts) in [(1, 1), (1, 8), (3, 2)].enumerated() {
|
||||||
var match = state.matches[0]
|
var match = state.matches[0]
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ struct ScanPage: View {
|
|||||||
} isTargeted: { targeted in
|
} isTargeted: { targeted in
|
||||||
folderTargeted = targeted
|
folderTargeted = targeted
|
||||||
}
|
}
|
||||||
|
ExpenseTemplateSummary {
|
||||||
|
store.templateMessage = nil
|
||||||
|
store.showExpenseTemplate = true
|
||||||
|
}
|
||||||
if !store.state.warnings.isEmpty {
|
if !store.state.warnings.isEmpty {
|
||||||
Panel {
|
Panel {
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
|||||||
+238
-17
@@ -20,10 +20,19 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
@Published private(set) var expenseMatchIDs: Set<String> = []
|
@Published private(set) var expenseMatchIDs: Set<String> = []
|
||||||
@Published var purposes: [String: String] = [:]
|
@Published var purposes: [String: String] = [:]
|
||||||
@Published var signatures = defaultSignatures
|
@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()
|
let payeeProfile = PayeeProfileStore()
|
||||||
private let engine = EngineBridge()
|
private let engine = EngineBridge()
|
||||||
private let storage: URL
|
private let storage: URL
|
||||||
private var cancelRequested = false
|
private var cancelRequested = false
|
||||||
|
private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) }
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
|
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
|
||||||
@@ -36,9 +45,139 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
errorMessage = "无法恢复上次工作区:\(error.localizedDescription)"
|
errorMessage = "无法恢复上次工作区:\(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
do {
|
||||||
|
expenseTemplate = try templateStorage.load()
|
||||||
|
if let expenseTemplate { _ = try templateStorage.file(for: expenseTemplate) }
|
||||||
|
} catch {
|
||||||
|
expenseTemplateLoadError = error.localizedDescription
|
||||||
|
}
|
||||||
restoreMatchExplanations()
|
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() {
|
private func restoreMatchExplanations() {
|
||||||
guard state.matches.contains(where: { $0.matchType == "auto" && $0.explanation?.version != 2 }) else { return }
|
guard state.matches.contains(where: { $0.matchType == "auto" && $0.explanation?.version != 2 }) else { return }
|
||||||
busy = true
|
busy = true
|
||||||
@@ -220,17 +359,31 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clear() {
|
func clear(includeTemplate: Bool = false) {
|
||||||
guard !busy else { return }
|
guard !busy else { return }
|
||||||
let directories = materialDirectories()
|
let directories = materialDirectories()
|
||||||
if commit(Workspace()) {
|
if commit(Workspace()) {
|
||||||
resetSelection()
|
resetSelection()
|
||||||
preview = nil
|
preview = nil
|
||||||
showExpense = false
|
showExpense = false
|
||||||
|
showExpensePreview = false
|
||||||
|
discardExpensePreview()
|
||||||
expenseMatchIDs = []
|
expenseMatchIDs = []
|
||||||
purposes = [:]
|
purposes = [:]
|
||||||
directories.forEach { try? FileManager.default.removeItem(at: $0) }
|
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 }
|
} catch { errorMessage = error.localizedDescription }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expenseExportRequest() throws -> (request: [String: Any], matches: [MatchGroup], template: SavedExpenseTemplate?) {
|
||||||
|
if let expenseTemplateLoadError { throw ExpenseTemplateFailure.message(expenseTemplateLoadError) }
|
||||||
|
let selected = try MatchedPPTSelection(ids: expenseMatchIDs).exportWorkspace(from: state)
|
||||||
|
var request: [String: Any] = [
|
||||||
|
"operation": "expense", "state": try jsonObject(selected), "preparePreview": true,
|
||||||
|
"purposes": purposes, "categoryPurposes": purposes, "signatures": signatures,
|
||||||
|
"payee": try payeeProfile.exportFields()
|
||||||
|
]
|
||||||
|
if let expenseTemplate {
|
||||||
|
request["templatePath"] = try templateStorage.file(for: expenseTemplate).path
|
||||||
|
request["templateMapping"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(expenseTemplate.mapping))
|
||||||
|
}
|
||||||
|
return (request, selected.matches, expenseTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardExpensePreview() {
|
||||||
|
expensePreview?.remove()
|
||||||
|
expensePreview = nil
|
||||||
|
expensePreviewError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareExpensePreview() {
|
||||||
|
guard !busy else { return }
|
||||||
|
discardExpensePreview()
|
||||||
|
showExpensePreview = true
|
||||||
|
do {
|
||||||
|
let snapshot = try expenseExportRequest()
|
||||||
|
let name = expenseTemplateName
|
||||||
|
let digest = try PreparedExpensePreview.digest(snapshot.request)
|
||||||
|
let file = storage.appendingPathComponent("expense-preview-\(UUID().uuidString).xlsx")
|
||||||
|
busy = true
|
||||||
|
status = "正在生成报销单预览"
|
||||||
|
progress = 0
|
||||||
|
Task {
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
var request = snapshot.request
|
||||||
|
request["destination"] = file.path
|
||||||
|
let result = try await run(request)
|
||||||
|
try ExpenseSummary.validateExportResult(result, matches: snapshot.matches)
|
||||||
|
if let template = snapshot.template { try ExpenseTemplateStorage.validateExport(result, template: template) }
|
||||||
|
let metadata = try JSONSerialization.jsonObject(with: result) as? [String: Any]
|
||||||
|
guard metadata?["expensePreview"] as? String == "xlsx-quicklook-v1" else {
|
||||||
|
throw ExpenseTemplateFailure.message("当前引擎不支持报销单预览,请重新构建应用。")
|
||||||
|
}
|
||||||
|
expensePreview = PreparedExpensePreview(
|
||||||
|
file: file, requestDigest: digest,
|
||||||
|
fileDigest: ExpenseTemplateStorage.fingerprint(try Data(contentsOf: file)),
|
||||||
|
templateName: name, uncalculatedCells: metadata?["previewUncalculatedCells"] as? [String] ?? []
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: file)
|
||||||
|
expensePreviewError = error is CancellationError ? "已取消生成,可返回修改或重新预览。" : error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { expensePreviewError = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveExpensePreview() {
|
||||||
|
guard !busy, let prepared = expensePreview else { return }
|
||||||
|
do {
|
||||||
|
_ = try prepared.verifiedData(request: expenseExportRequest().request)
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data]
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd-HHmm"
|
||||||
|
panel.nameFieldStringValue = "个人报销单-\(formatter.string(from: Date())).xlsx"
|
||||||
|
guard panel.runModal() == .OK, let destination = panel.url else { return }
|
||||||
|
let scoped = destination.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { destination.stopAccessingSecurityScopedResource() } }
|
||||||
|
let data = try prepared.verifiedData(request: expenseExportRequest().request)
|
||||||
|
try data.write(to: destination, options: .atomic)
|
||||||
|
notice = "个人报销单已导出到 \(destination.lastPathComponent)"
|
||||||
|
showExpensePreview = false
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
||||||
|
} catch { expensePreviewError = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
func export(_ kind: ExportKind, selectedMatchIDs: Set<String>? = nil) {
|
func export(_ kind: ExportKind, selectedMatchIDs: Set<String>? = nil) {
|
||||||
guard !busy, !state.matches.isEmpty else { return }
|
guard !busy, !state.matches.isEmpty else { return }
|
||||||
|
if kind == .expense { prepareExpensePreview(); return }
|
||||||
let exportState: Workspace
|
let exportState: Workspace
|
||||||
let payeeFields: [String: String]
|
|
||||||
do {
|
do {
|
||||||
payeeFields = kind == .expense ? try payeeProfile.exportFields() : [:]
|
if kind == .ppt, let selectedMatchIDs {
|
||||||
if kind == .expense {
|
|
||||||
exportState = try MatchedPPTSelection(ids: expenseMatchIDs).exportWorkspace(from: state)
|
|
||||||
} else if kind == .ppt, let selectedMatchIDs {
|
|
||||||
exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state)
|
exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state)
|
||||||
} else {
|
} else {
|
||||||
exportState = state
|
exportState = state
|
||||||
@@ -288,17 +517,9 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
try? FileManager.default.removeItem(at: temporary)
|
try? FileManager.default.removeItem(at: temporary)
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
var request: [String: Any] = ["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]
|
let request: [String: Any] = ["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]
|
||||||
if kind == .expense {
|
_ = try await run(request)
|
||||||
request["payee"] = payeeFields
|
|
||||||
request["categoryPurposes"] = purposes
|
|
||||||
}
|
|
||||||
let result = try await run(request)
|
|
||||||
if kind == .expense {
|
|
||||||
try ExpenseSummary.validateExportResult(result, matches: exportState.matches)
|
|
||||||
}
|
|
||||||
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
||||||
if kind == .expense { showExpense = false }
|
|
||||||
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user