This commit is contained in:
csj
2026-09-16 21:02:44 +08:00
parent 7fea2063aa
commit 45b6f693d6
11 changed files with 320 additions and 86 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。 默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。
导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个 PPT 排版函数:1280×720,发票大图、一对一申请两笔一批拼付款截图、多对多付款截图每页最多四张,非交通/住宿保留实物照片手动粘贴占位页。交通差旅映射为本地“交通”、住宿费用映射为“住宿”。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或其他文字。同组同人员连续申请沿用本地合页规则,跨组或跨账户时结束当前批次;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。 导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个竖版 3:4 排版函数(720×960 pt):单票单付同页,右下预留实物照片区;多发票每页最多两张上下排列,剩余单张发票可与前两张付款同页,付款续页为三列两行、最多六张。复杂组最后追加一张手工照片页,所有类别均预留照片位置。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或贴票类型标题。每笔申请独立排版,不与其他申请合页;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。
可取消、失败后保留选择、成功后在 Finder 中显示文件。中间文件使用独立临时目录并清理,完成后才原子写入所选位置;不改动本地贴票工作区,不执行 OCR 自动匹配,也不改变报销状态。原本地贴票导出逻辑保持不变。 可取消、失败后保留选择、成功后在 Finder 中显示文件。中间文件使用独立临时目录并清理,完成后才原子写入所选位置;不改动本地贴票工作区,不执行 OCR 自动匹配,也不改变报销状态。原本地贴票导出逻辑保持不变。
+5 -1
View File
@@ -74,7 +74,11 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
- 页面发票汇总、OCR 首金额和个人报销单最大金额的不同计算口径沿用旧代码,未擅自合并。 - 页面发票汇总、OCR 首金额和个人报销单最大金额的不同计算口径沿用旧代码,未擅自合并。
- 订单截图与同金额支付凭证去重沿用旧规则,因此仍需人工检查不同订单同金额的情况。 - 订单截图与同金额支付凭证去重沿用旧规则,因此仍需人工检查不同订单同金额的情况。
- “按类型分类”PPT 沿用先日期、后类别排序,并非重新设计分类封面或类别分区。 - “按类型分类”PPT 沿用先日期、后类别排序,并非重新设计分类封面或类别分区。
- 实物照片不自动填入 PPT;除交通、住宿外保留手动粘贴占位页 - PPT 使用竖版 3:4(720×960 pt),纯白页面,不加封面或贴票类型标题。所有类别(包括交通、住宿)均预留实物照片位置,不自动填入实物照片
- 一张发票配一张付款截图:发票上方整幅,付款截图左下,右下留可编辑、可删除的实物照片提示,不另加照片页。
- 一张发票配多张付款截图:首张发票页下方左右最多放两张付款,剩余付款按每页三列两行、最多六张续页,最后加一张手工照片页。
- 多张发票:先按每页两张上下排列;若剩一张,放在下一页上方,下方容纳前两张付款。剩余付款每页最多六张,最后加一张手工照片页。只有发票或只有付款的组也能完整导出并预留照片页。
- 不同核对组或不同审批申请不合页、不按比例猜测发票付款对应关系;原图等比缩放,不裁切、不重复、不漏图,PDF 仍只用首页。
- 个人报销单沿用原始 XLSX 模板,直接修改工作表 XML,保留样式、打印设置、公式和关联资源;每页 13 条明细,最多六个签字岗位。删除全部岗位时按旧规则回退默认岗位。 - 个人报销单沿用原始 XLSX 模板,直接修改工作表 XML,保留样式、打印设置、公式和关联资源;每页 13 条明细,最多六个签字岗位。删除全部岗位时按旧规则回退默认岗位。
### 必须说明的环境差异 ### 必须说明的环境差异
+2
View File
@@ -17,6 +17,8 @@ SOURCE_HASH="$(bash "$ROOT/native-engine/engine-fingerprint.sh")"
--add-data "personal-expense-template.xlsx:." engine.py --add-data "personal-expense-template.xlsx:." engine.py
BINARY="$ROOT/native-engine/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper" BINARY="$ROOT/native-engine/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper"
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_exports.py -k engine_process -v RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_exports.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
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
+1 -1
View File
@@ -93,7 +93,7 @@ def dispatch(request):
metadata = {} metadata = {}
try: try:
if operation in ('ppt', 'approved-ppt'): if operation in ('ppt', 'approved-ppt'):
export_ppt(state, temporary, request.get('classified', True)) metadata = export_ppt(state, temporary, request.get('classified', True))
elif operation == 'travel': elif operation == 'travel':
export_travel(state, temporary) export_travel(state, temporary)
elif operation == 'expense': elif operation == 'expense':
+66 -68
View File
@@ -54,7 +54,7 @@ def export_approved_ppt(records, destination, classified):
if not match['invoices'] and not match['payments']: if not match['invoices'] and not match['payments']:
raise ValueError(f"申请 #{record['id']} 没有发票或付款截图,无法按本地贴票格式导出,请取消选择该申请") raise ValueError(f"申请 #{record['id']} 没有发票或付款截图,无法按本地贴票格式导出,请取消选择该申请")
matches.append(match) matches.append(match)
export_ppt(dict(matches=matches), destination, classified, preserve_order=True) return export_ppt(dict(matches=matches), destination, classified, preserve_order=True)
def export_ppt(state, destination, classified, preserve_order=False): def export_ppt(state, destination, classified, preserve_order=False):
@@ -64,92 +64,90 @@ def export_ppt(state, destination, classified, preserve_order=False):
from pptx import Presentation from pptx import Presentation
from pptx.util import Pt from pptx.util import Pt
from pptx.dml.color import RGBColor from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.text import PP_ALIGN
presentation = Presentation() presentation = Presentation()
presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720) presentation.slide_width, presentation.slide_height = Pt(720), Pt(960)
def slide(): def slide():
return presentation.slides.add_slide(presentation.slide_layouts[6]) page = presentation.slides.add_slide(presentation.slide_layouts[6])
page.background.fill.solid()
page.background.fill.fore_color.rgb = RGBColor(255, 255, 255)
return page
def picture(page, item, box): def picture(page, item, box, kind):
image = image_for(item)
left, top, width, height = box left, top, width, height = box
with image_for(item) as image:
scale = min(width / image.width, height / image.height) scale = min(width / image.width, height / image.height)
actual_width, actual_height = int(image.width * scale), int(image.height * scale) actual_width, actual_height = image.width * scale, image.height * scale
content = io.BytesIO() with io.BytesIO() as content:
image.save(content, format='PNG') image.save(content, format='PNG')
content.seek(0) content.seek(0)
page.shapes.add_picture(content, Pt(left + (width - actual_width) // 2), Pt(top + (height - actual_height) // 2), Pt(actual_width), Pt(actual_height)) shape = page.shapes.add_picture(
content, Pt(left + (width - actual_width) / 2), Pt(top + (height - actual_height) / 2),
Pt(actual_width), Pt(actual_height)
)
shape.name = f'{kind}-{len(page.shapes)}'
def payment_page(payments): def photo_hint(page, box):
page = slide() shape = page.shapes.add_textbox(*(Pt(value) for value in box))
height = 680 shape.name = 'manual-photo-area'
if len(payments) == 1: frame = shape.text_frame
picture(page, payments[0], (300, 20, 680, height)) frame.clear()
else: frame.vertical_anchor = MSO_ANCHOR.MIDDLE
width = (1200 - 24 * (len(payments) - 1)) // len(payments) frame.word_wrap = True
for index, item in enumerate(payments): frame.text = '实物照片粘贴区\n请手动粘贴,可删除此提示'
picture(page, item, (40 + index * (width + 24), 20, width, height)) for paragraph in frame.paragraphs:
def label(page, box, value, size, title=False):
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(245, 248, 247) if title else RGBColor(255, 255, 255)
shape.line.color.rgb = RGBColor(216, 226, 222) if title else RGBColor(170, 188, 182)
shape.line.width = Pt(1 if title else 1.5)
shape.text = value
for paragraph in shape.text_frame.paragraphs:
paragraph.alignment = PP_ALIGN.CENTER paragraph.alignment = PP_ALIGN.CENTER
for run in paragraph.runs: for run in paragraph.runs:
run.font.name = 'PingFang SC' run.font.name = 'PingFang SC'
run.font.size = Pt(size) run.font.size = Pt(14)
run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126) run.font.color.rgb = RGBColor(155, 155, 155)
def photos(count): def combined_page(invoice, payments, inline_photo=False):
for offset in range(0, count, 4):
slots = min(4, count - offset)
page = slide() page = slide()
label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True) picture(page, invoice, (48, 44, 624, 360), 'invoice')
for index in range(slots): for index, item in enumerate(payments):
box = (250, 105, 780, 555) if slots == 1 else (55 + index * 610, 105, 560, 555) if slots == 2 else (55 + index % 2 * 610, 105 + index // 2 * 285, 560, 255) picture(page, item, (48 + index * 324, 444, 300, 468), 'payment')
label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16) if inline_photo:
photo_hint(page, (372, 444, 300, 468))
def requires_photo(match): def payment_pages(payments):
return match['category'].strip() not in ['交通', '住宿'] for offset in range(0, len(payments), 6):
page = slide()
for index, item in enumerate(payments[offset:offset + 6]):
box = (48 + index % 3 * 216, 48 + index // 3 * 444, 192, 420)
picture(page, item, box, 'payment')
def simple_batch(batch): matches = state['matches'] if preserve_order else sorted(
if not batch: state['matches'],
return key=lambda match: (
for match in batch: min((material_date(item, True) for item in match['invoices']), default='9999-12-31'),
picture(slide(), match['invoices'][0], (40, 25, 1200, 670)) match['category'] if classified else ''
payment_page([match['payments'][0] for match in batch]) )
photos(sum(len(match['payments']) for match in batch if requires_photo(match))) )
if not matches:
matches = state['matches'] if preserve_order else sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else '')) raise ValueError('请先选择要导出的贴票材料')
batch = []
for match in matches: for match in matches:
if batch and batch[-1].get('scope') != match.get('scope'): invoices = sorted(match.get('invoices', []), key=material_date)
simple_batch(batch) payments = sorted(match.get('payments', []), key=material_date)
batch = [] if not invoices and not payments:
if len(match['invoices']) != 1 or len(match['payments']) != 1: raise ValueError('所选组没有发票或付款截图,无法导出')
simple_batch(batch) if len(invoices) == 1 and len(payments) == 1:
batch = [] combined_page(invoices[0], payments, inline_photo=True)
for item in sorted(match['invoices'], key=material_date): continue
picture(slide(), item, (40, 25, 1200, 670)) paired_count = len(invoices) - len(invoices) % 2
payments = sorted(match['payments'], key=material_date) for offset in range(0, paired_count, 2):
for index in range(0, len(payments), 4): page = slide()
payment_page(payments[index:index + 4]) picture(page, invoices[offset], (48, 44, 624, 408), 'invoice')
if requires_photo(match): picture(page, invoices[offset + 1], (48, 508, 624, 408), 'invoice')
photos(len(payments)) if len(invoices) % 2:
else: combined_page(invoices[-1], payments[:2])
batch.append(match) payments = payments[2:]
if len(batch) == 2: payment_pages(payments)
simple_batch(batch) photo_hint(slide(), (48, 48, 624, 864))
batch = []
simple_batch(batch)
presentation.save(destination) presentation.save(destination)
return dict(pptLayout='portrait-receipts-v1', pptSlideCount=len(presentation.slides))
def export_travel(state, destination): def export_travel(state, destination):
+13 -4
View File
@@ -1,4 +1,5 @@
import copy import copy
import io
import json import json
import os import os
import subprocess import subprocess
@@ -65,7 +66,12 @@ class ApprovedPPTTests(unittest.TestCase):
second = Image.new('RGB', (300, 200), 'blue') second = Image.new('RGB', (300, 200), 'blue')
first.save(pdf, save_all=True, append_images=[second]) first.save(pdf, save_all=True, append_images=[second])
self.record['materials'] = [self.material('invoice', pdf)] self.record['materials'] = [self.material('invoice', pdf)]
self.assertEqual(len(self.export([self.record]).slides), 1) deck = self.export([self.record])
self.assertEqual(len(deck.slides), 2)
pictures = [shape for page in deck.slides for shape in page.shapes if shape.shape_type == 13]
self.assertEqual(len(pictures), 1)
with Image.open(io.BytesIO(pictures[0].image.blob)) as preview:
self.assertEqual(preview.convert('RGB').getpixel((0, 0)), (255, 255, 255))
def test_preserves_selected_order_without_merging_people(self): def test_preserves_selected_order_without_merging_people(self):
other = copy.deepcopy(self.record) other = copy.deepcopy(self.record)
@@ -79,7 +85,7 @@ class ApprovedPPTTests(unittest.TestCase):
self.assertEqual(deck.slides[0].shapes[0].image.blob, self.image.read_bytes()) self.assertEqual(deck.slides[0].shapes[0].image.blob, self.image.read_bytes())
self.assertEqual(deck.slides[2].shapes[0].image.blob, blue.read_bytes()) self.assertEqual(deck.slides[2].shapes[0].image.blob, blue.read_bytes())
def test_no_added_metadata_and_same_simple_batch_layout(self): def test_no_added_metadata_and_same_per_group_layout(self):
self.record['note'] = '采购备注' * 250 self.record['note'] = '采购备注' * 250
self.record['materials'] = [self.material('invoice'), self.material('payment')] self.record['materials'] = [self.material('invoice'), self.material('payment')]
deck = self.export([self.record, copy.deepcopy(self.record)]) deck = self.export([self.record, copy.deepcopy(self.record)])
@@ -108,7 +114,7 @@ class ApprovedPPTTests(unittest.TestCase):
self.record.update(groupId=1, userId='1', materials=[self.material('invoice'), self.material('payment')]) self.record.update(groupId=1, userId='1', materials=[self.material('invoice'), self.material('payment')])
other = copy.deepcopy(self.record) other = copy.deepcopy(self.record)
other.update(userId='2') other.update(userId='2')
self.assertEqual(len(self.export([self.record, other]).slides), 6) self.assertEqual(len(self.export([self.record, other]).slides), 2)
def test_failure_keeps_existing_output(self): def test_failure_keeps_existing_output(self):
destination = self.root / 'original.pptx' destination = self.root / 'original.pptx'
@@ -135,7 +141,10 @@ class ApprovedPPTTests(unittest.TestCase):
events = [json.loads(line) for line in process.stdout.splitlines()] events = [json.loads(line) for line in process.stdout.splitlines()]
self.assertEqual(events[-1]['event'], 'result') self.assertEqual(events[-1]['event'], 'result')
self.assertEqual(events[-1]['result']['destination'], str(destination)) self.assertEqual(events[-1]['result']['destination'], str(destination))
self.assertEqual(len(Presentation(destination).slides), 3) deck = Presentation(destination)
self.assertEqual(len(deck.slides), 1)
self.assertEqual((deck.slide_width, deck.slide_height), (720 * 12700, 960 * 12700))
self.assertEqual(events[-1]['result']['pptLayout'], 'portrait-receipts-v1')
if __name__ == '__main__': if __name__ == '__main__':
+34 -6
View File
@@ -34,21 +34,49 @@ class ExportTests(unittest.TestCase):
destination = self.root / 'test.pptx' destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True) export_ppt(self.state, destination, True)
deck = Presentation(destination) deck = Presentation(destination)
self.assertEqual(len(deck.slides), 3) self.assertEqual(len(deck.slides), 1)
self.assertEqual(deck.slide_width, 1280 * 12700) self.assertEqual(deck.slide_width, 720 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[2].shapes if shape.has_text_frame)) self.assertEqual(deck.slide_height, 960 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[0].shapes if shape.has_text_frame))
def test_travel_category_excludes_photo(self): def test_travel_category_also_reserves_photo_area(self):
self.state['matches'][0]['category'] = '交通' self.state['matches'][0]['category'] = '交通'
destination = self.root / 'test.pptx' destination = self.root / 'test.pptx'
export_ppt(self.state, destination, False) export_ppt(self.state, destination, False)
self.assertEqual(len(Presentation(destination).slides), 2) deck = Presentation(destination)
self.assertEqual(len(deck.slides), 1)
self.assertTrue(any(shape.name == 'manual-photo-area' for shape in deck.slides[0].shapes))
def test_complex_payment_pagination(self): def test_complex_payment_pagination(self):
self.state['matches'][0]['payments'] *= 5 self.state['matches'][0]['payments'] *= 5
destination = self.root / 'test.pptx' destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True) export_ppt(self.state, destination, True)
self.assertEqual(len(Presentation(destination).slides), 5) self.assertEqual(len(Presentation(destination).slides), 3)
def test_ppt_layouts_are_portrait_and_reserve_photo_page(self):
scenarios = []
single = copy.deepcopy(self.state['matches'][0])
scenarios.append(single)
one_invoice_many_payments = copy.deepcopy(single)
one_invoice_many_payments['payments'] *= 3
scenarios.append(one_invoice_many_payments)
many_invoices_many_payments = copy.deepcopy(single)
many_invoices_many_payments['invoices'] *= 2
many_invoices_many_payments['payments'] *= 2
scenarios.append(many_invoices_many_payments)
for index, match in enumerate(scenarios):
destination = self.root / f'layout-{index}.pptx'
export_ppt(dict(matches=[match]), destination, True)
deck = Presentation(destination)
self.assertEqual(deck.slide_width, 720 * 12700)
self.assertEqual(deck.slide_height, 960 * 12700)
self.assertGreaterEqual(len(deck.slides), 1)
self.assertTrue(any(
any('实物照片粘贴区' in shape.text for shape in slide.shapes if shape.has_text_frame)
for slide in deck.slides
))
self.assertEqual(sum(shape.name.startswith('payment-') for slide in deck.slides for shape in slide.shapes),
len(match['payments']))
def test_expense_template_values_and_formulas(self): def test_expense_template_values_and_formulas(self):
destination = self.root / 'expense.xlsx' destination = self.root / 'expense.xlsx'
+169
View File
@@ -0,0 +1,169 @@
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import unittest
from collections import Counter
from pathlib import Path
from PIL import Image, ImageDraw
from pptx import Presentation
from pptx.util import Pt
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exports import export_ppt
def fixture(root, kind, index, size=None):
dimensions = size or ((1200, 700) if kind == 'invoice' else (360, 900))
path = root / f'{kind}-{index}.png'
image = Image.new('RGB', dimensions, 'white')
drawing = ImageDraw.Draw(image)
color = '#ab3d3d' if kind == 'invoice' else '#285b50'
drawing.rectangle((10, 10, dimensions[0] - 10, dimensions[1] - 10), outline=color, width=3)
drawing.text((30, 30), f'{kind.upper()} {index + 1}', fill=color, font_size=28)
if kind == 'invoice':
for fraction in [0.25, 0.4, 0.72, 0.82]:
height = int(dimensions[1] * fraction)
drawing.line((20, height, dimensions[0] - 20, height), fill=color, width=2)
drawing.text((40, dimensions[1] // 2), 'Receipt layout test - NOT A REAL INVOICE', fill=color, font_size=24)
else:
drawing.text((40, 130), 'PAYMENT RECEIPT', fill=color, font_size=22)
drawing.text((40, 240), f'Test amount: {index + 1}.00', fill=color, font_size=20)
for row in range(6):
drawing.text((40, 340 + row * 55), f'Test payment field {row + 1}', fill=color, font_size=16)
image.save(path)
return dict(id=f'{kind}-{index}', path=str(path), ocr=dict(rawText='', dates=['2026-09-16']))
class PortraitPPTTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.invoices = [fixture(self.root, 'invoice', index) for index in range(5)]
self.payments = [fixture(self.root, 'payment', index) for index in range(15)]
def tearDown(self):
self.temporary.cleanup()
def match(self, invoices, payments):
return dict(invoices=self.invoices[:invoices], payments=self.payments[:payments], category='交通')
def export(self, invoices, payments):
destination = self.root / 'portrait.pptx'
export_ppt(dict(matches=[self.match(invoices, payments)]), destination, True)
return Presentation(destination)
def pictures(self, slide, kind=None):
return [shape for shape in slide.shapes if shape.shape_type == 13 and
(kind is None or shape.name.startswith(kind + '-'))]
def assert_geometry_and_materials(self, deck, invoices, payments):
self.assertEqual((deck.slide_width, deck.slide_height), (Pt(720), Pt(960)))
expected = Counter(hashlib.sha256(Path(item['path']).read_bytes()).hexdigest()
for item in self.invoices[:invoices] + self.payments[:payments])
actual = Counter(hashlib.sha256(shape.image.blob).hexdigest()
for page in deck.slides for shape in self.pictures(page))
self.assertEqual(actual, expected)
for page in deck.slides:
invoice_shapes = self.pictures(page, 'invoice')
payment_shapes = self.pictures(page, 'payment')
self.assertLessEqual(len(invoice_shapes), 2)
self.assertLessEqual(len(payment_shapes), 6)
if len(invoice_shapes) == 2:
self.assertEqual(len(payment_shapes), 0)
self.assertLessEqual(invoice_shapes[0].top + invoice_shapes[0].height, invoice_shapes[1].top)
if len(invoice_shapes) == 1:
self.assertLessEqual(len(payment_shapes), 2)
for payment in payment_shapes:
self.assertLessEqual(invoice_shapes[0].top + invoice_shapes[0].height, payment.top)
for shape in page.shapes:
self.assertGreaterEqual(shape.left, 0)
self.assertGreaterEqual(shape.top, 0)
self.assertLessEqual(shape.left + shape.width, deck.slide_width)
self.assertLessEqual(shape.top + shape.height, deck.slide_height)
for index, shape in enumerate(page.shapes):
for other in list(page.shapes)[index + 1:]:
separated = (shape.left + shape.width <= other.left or other.left + other.width <= shape.left or
shape.top + shape.height <= other.top or other.top + other.height <= shape.top)
self.assertTrue(separated, (shape.name, other.name))
if shape.shape_type == 13:
image_width, image_height = shape.image.size
self.assertAlmostEqual(shape.width / shape.height, image_width / image_height, places=4)
self.assertEqual((shape.crop_left, shape.crop_right, shape.crop_top, shape.crop_bottom), (0, 0, 0, 0))
def test_single_pair_leaves_lower_right_photo_area(self):
deck = self.export(1, 1)
self.assertEqual(len(deck.slides), 1)
page = deck.slides[0]
payment = self.pictures(page, 'payment')[0]
photo = next(shape for shape in page.shapes if shape.name == 'manual-photo-area')
self.assertLessEqual(payment.left + payment.width, photo.left)
self.assert_geometry_and_materials(deck, 1, 1)
def test_single_invoice_eight_payments_matches_reference_pages(self):
deck = self.export(1, 8)
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [3, 6, 0])
grid = self.pictures(deck.slides[1])
self.assertEqual(len({shape.top for shape in grid}), 2)
self.assertEqual(len({shape.left for shape in grid}), 3)
self.assertEqual(deck.slides[2].shapes[0].name, 'manual-photo-area')
self.assert_geometry_and_materials(deck, 1, 8)
def test_three_invoices_two_payments_matches_reference_pages(self):
deck = self.export(3, 2)
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [2, 3, 0])
self.assertEqual(len(self.pictures(deck.slides[0], 'invoice')), 2)
self.assertEqual(len(self.pictures(deck.slides[1], 'invoice')), 1)
self.assert_geometry_and_materials(deck, 3, 2)
def test_all_counts_preserve_materials_without_overlap_or_cropping(self):
for invoices in range(6):
for payments in [0, 1, 2, 3, 5, 8, 9, 15]:
if not invoices and not payments:
continue
with self.subTest(invoices=invoices, payments=payments):
deck = self.export(invoices, payments)
self.assert_geometry_and_materials(deck, invoices, payments)
if (invoices, payments) != (1, 1):
self.assertEqual(len(self.pictures(deck.slides[-1])), 0)
self.assertEqual(deck.slides[-1].shapes[0].name, 'manual-photo-area')
def test_groups_are_not_mixed_and_no_photos_are_automatically_added(self):
destination = self.root / 'two-groups.pptx'
state = dict(matches=[self.match(1, 1), self.match(1, 1)],
photos=[fixture(self.root, 'photo', 0)])
export_ppt(state, destination, True)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 2)
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [2, 2])
def test_portrait_and_extreme_aspect_ratio_sources_stay_in_bounds(self):
self.invoices[0] = fixture(self.root, 'invoice', 0, (700, 1400))
self.payments[0] = fixture(self.root, 'payment', 0, (300, 2500))
self.assert_geometry_and_materials(self.export(1, 1), 1, 1)
def test_empty_group_fails_instead_of_silently_omitting_it(self):
with self.assertRaisesRegex(ValueError, '没有发票'):
self.export(0, 0)
def test_portrait_engine_process_protocol(self):
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
destination = self.root / 'process.pptx'
request = dict(operation='ppt', state=dict(matches=[self.match(1, 8), self.match(3, 2)]),
destination=str(destination))
process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60)
self.assertEqual(process.returncode, 0, process.stdout + process.stderr)
result = json.loads(process.stdout.splitlines()[-1])['result']
self.assertEqual(result['pptLayout'], 'portrait-receipts-v1')
deck = Presentation(destination)
self.assertEqual(result['pptSlideCount'], 6)
self.assertEqual((deck.slide_width, deck.slide_height), (Pt(720), Pt(960)))
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [3, 6, 0, 2, 3, 0])
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -203,7 +203,7 @@ struct AdminPPTExportSheet: View {
Spacer() Spacer()
Button("关闭") { dismiss() }.disabled(exporter.busy) Button("关闭") { dismiss() }.disabled(exporter.busy)
} }
Text("与本地贴票使用相同 PPT 格式:发票大图、付款截图拼页、实物照片手动粘贴区;PDF 仅使用首页。不添加封面、归属页、备注或页脚,不自动贴入照片其他材料。") Text("与本地贴票使用相同竖版 3:4 格式:单票上方、付款下方;多张发票每页上下排两张,付款续页最多六张。单票单付右下留照片区,其他情况另留照片页。PDF 仅使用首页,不自动贴入实物照片其他材料。")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
Text("导出范围:\(scopeText)").font(.callout.weight(.medium)) Text("导出范围:\(scopeText)").font(.callout.weight(.medium))
HStack { HStack {
+2
View File
@@ -60,6 +60,8 @@ struct MatchedPage: View {
}.font(.callout) }.font(.callout)
Text("切换分组保留勾选;PPT 和个人报销单仅导出所选材料,报销单按费用类型合并。行程 Excel 仍使用全部已核对材料。") Text("切换分组保留勾选;PPT 和个人报销单仅导出所选材料,报销单按费用类型合并。行程 Excel 仍使用全部已核对材料。")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
Text("PPT 使用竖版 3:4:每页最多两张发票,付款续页最多六张,实物照片留空供手动粘贴。")
.font(.caption).foregroundStyle(.secondary)
} }
}.padding(28).disabled(store.busy) }.padding(28).disabled(store.busy)
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in .onChange(of: store.state.matches.map(\.id)) { _, identifiers in
+22
View File
@@ -88,6 +88,28 @@ 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
var portraitState = Workspace()
for (index, counts) in [(1, 1), (1, 8), (3, 2)].enumerated() {
var match = state.matches[0]
match.id = "portrait-\(index)"
match.invoices = Array(repeating: state.invoices[0], count: counts.0)
match.payments = Array(repeating: state.payments[0], count: counts.1)
portraitState.matches.append(match)
}
let portraitDestination = root.appendingPathComponent("portrait-receipts.pptx")
let portraitRequest: [String: Any] = [
"operation": "ppt",
"state": try JSONSerialization.jsonObject(with: JSONEncoder().encode(portraitState)),
"destination": portraitDestination.path
]
let portraitData = try await bridge.run(request: JSONSerialization.data(withJSONObject: portraitRequest)) { _, _ in }
let portraitResult = try JSONSerialization.jsonObject(with: portraitData) as? [String: Any]
guard portraitResult?["pptLayout"] as? String == "portrait-receipts-v1",
portraitResult?["pptSlideCount"] as? Int == 7 else {
throw EngineFailure.message("竖版贴票引擎版本或分页不正确")
}
report["portraitPPT"] = "passed"
report["portraitPPTPath"] = portraitDestination.path
try JSONEncoder().encode(state).write(to: root.appendingPathComponent("state.json")) try JSONEncoder().encode(state).write(to: root.appendingPathComponent("state.json"))
report["result"] = "passed" report["result"] = "passed"
report["directory"] = root.path report["directory"] = root.path