diff --git a/PROJECT-ADMIN.md b/PROJECT-ADMIN.md index 559489c..1197852 100644 --- a/PROJECT-ADMIN.md +++ b/PROJECT-ADMIN.md @@ -6,7 +6,7 @@ 默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。 -导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个 PPT 排版函数:1280×720,发票大图、一对一申请两笔一批拼付款截图、多对多付款截图每页最多四张,非交通/住宿保留实物照片手动粘贴占位页。交通差旅映射为本地“交通”、住宿费用映射为“住宿”。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或其他文字。同组同人员连续申请沿用本地合页规则,跨组或跨账户时结束当前批次;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。 +导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个竖版 3:4 排版函数(720×960 pt):单票单付同页,右下预留实物照片区;多发票每页最多两张上下排列,剩余单张发票可与前两张付款同页,付款续页为三列两行、最多六张。复杂组最后追加一张手工照片页,所有类别均预留照片位置。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或贴票类型标题。每笔申请独立排版,不与其他申请合页;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。 可取消、失败后保留选择、成功后在 Finder 中显示文件。中间文件使用独立临时目录并清理,完成后才原子写入所选位置;不改动本地贴票工作区,不执行 OCR 自动匹配,也不改变报销状态。原本地贴票导出逻辑保持不变。 diff --git a/README.md b/README.md index b2f1bd6..1969a89 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,11 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ - 页面发票汇总、OCR 首金额和个人报销单最大金额的不同计算口径沿用旧代码,未擅自合并。 - 订单截图与同金额支付凭证去重沿用旧规则,因此仍需人工检查不同订单同金额的情况。 - “按类型分类”PPT 沿用先日期、后类别排序,并非重新设计分类封面或类别分区。 -- 实物照片不自动填入 PPT;除交通、住宿外保留手动粘贴占位页。 +- PPT 使用竖版 3:4(720×960 pt),纯白页面,不加封面或贴票类型标题。所有类别(包括交通、住宿)均预留实物照片位置,不自动填入实物照片。 +- 一张发票配一张付款截图:发票上方整幅,付款截图左下,右下留可编辑、可删除的实物照片提示,不另加照片页。 +- 一张发票配多张付款截图:首张发票页下方左右最多放两张付款,剩余付款按每页三列两行、最多六张续页,最后加一张手工照片页。 +- 多张发票:先按每页两张上下排列;若剩一张,放在下一页上方,下方容纳前两张付款。剩余付款每页最多六张,最后加一张手工照片页。只有发票或只有付款的组也能完整导出并预留照片页。 +- 不同核对组或不同审批申请不合页、不按比例猜测发票付款对应关系;原图等比缩放,不裁切、不重复、不漏图,PDF 仍只用首页。 - 个人报销单沿用原始 XLSX 模板,直接修改工作表 XML,保留样式、打印设置、公式和关联资源;每页 13 条明细,最多六个签字岗位。删除全部岗位时按旧规则回退默认岗位。 ### 必须说明的环境差异 diff --git a/native-engine/build-engine.sh b/native-engine/build-engine.sh index 4c314ca..bab461f 100644 --- a/native-engine/build-engine.sh +++ b/native-engine/build-engine.sh @@ -17,6 +17,8 @@ SOURCE_HASH="$(bash "$ROOT/native-engine/engine-fingerprint.sh")" --add-data "personal-expense-template.xlsx:." engine.py 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_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 echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2 exit 1 diff --git a/native-engine/engine.py b/native-engine/engine.py index 2f5262d..38cfc0c 100644 --- a/native-engine/engine.py +++ b/native-engine/engine.py @@ -93,7 +93,7 @@ def dispatch(request): metadata = {} try: 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': export_travel(state, temporary) elif operation == 'expense': diff --git a/native-engine/exports.py b/native-engine/exports.py index 69c4271..b091341 100644 --- a/native-engine/exports.py +++ b/native-engine/exports.py @@ -54,7 +54,7 @@ def export_approved_ppt(records, destination, classified): if not match['invoices'] and not match['payments']: raise ValueError(f"申请 #{record['id']} 没有发票或付款截图,无法按本地贴票格式导出,请取消选择该申请") 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): @@ -64,92 +64,90 @@ def export_ppt(state, destination, classified, preserve_order=False): from pptx import Presentation from pptx.util import Pt from pptx.dml.color import RGBColor - from pptx.enum.shapes import MSO_SHAPE - from pptx.enum.text import PP_ALIGN + from pptx.enum.text import PP_ALIGN, MSO_ANCHOR presentation = Presentation() - presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720) + presentation.slide_width, presentation.slide_height = Pt(720), Pt(960) 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): - image = image_for(item) + def picture(page, item, box, kind): left, top, width, height = box - scale = min(width / image.width, height / image.height) - actual_width, actual_height = int(image.width * scale), int(image.height * scale) - content = io.BytesIO() - image.save(content, format='PNG') - 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)) + with image_for(item) as image: + scale = min(width / image.width, height / image.height) + actual_width, actual_height = image.width * scale, image.height * scale + with io.BytesIO() as content: + image.save(content, format='PNG') + content.seek(0) + 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): - page = slide() - height = 680 - if len(payments) == 1: - picture(page, payments[0], (300, 20, 680, height)) - else: - width = (1200 - 24 * (len(payments) - 1)) // len(payments) - for index, item in enumerate(payments): - picture(page, item, (40 + index * (width + 24), 20, width, height)) - - 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: + def photo_hint(page, box): + shape = page.shapes.add_textbox(*(Pt(value) for value in box)) + shape.name = 'manual-photo-area' + frame = shape.text_frame + frame.clear() + frame.vertical_anchor = MSO_ANCHOR.MIDDLE + frame.word_wrap = True + frame.text = '实物照片粘贴区\n请手动粘贴,可删除此提示' + for paragraph in frame.paragraphs: paragraph.alignment = PP_ALIGN.CENTER for run in paragraph.runs: run.font.name = 'PingFang SC' - run.font.size = Pt(size) - run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126) + run.font.size = Pt(14) + run.font.color.rgb = RGBColor(155, 155, 155) - def photos(count): - for offset in range(0, count, 4): - slots = min(4, count - offset) + def combined_page(invoice, payments, inline_photo=False): + page = slide() + picture(page, invoice, (48, 44, 624, 360), 'invoice') + for index, item in enumerate(payments): + picture(page, item, (48 + index * 324, 444, 300, 468), 'payment') + if inline_photo: + photo_hint(page, (372, 444, 300, 468)) + + def payment_pages(payments): + for offset in range(0, len(payments), 6): page = slide() - label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True) - for index in range(slots): - 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) - label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16) + 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 requires_photo(match): - return match['category'].strip() not in ['交通', '住宿'] - - def simple_batch(batch): - if not batch: - return - for match in batch: - picture(slide(), match['invoices'][0], (40, 25, 1200, 670)) - payment_page([match['payments'][0] for match in batch]) - photos(sum(len(match['payments']) for match in batch if requires_photo(match))) - - 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 '')) - batch = [] + matches = state['matches'] if preserve_order else sorted( + state['matches'], + key=lambda match: ( + min((material_date(item, True) for item in match['invoices']), default='9999-12-31'), + match['category'] if classified else '' + ) + ) + if not matches: + raise ValueError('请先选择要导出的贴票材料') for match in matches: - if batch and batch[-1].get('scope') != match.get('scope'): - simple_batch(batch) - batch = [] - if len(match['invoices']) != 1 or len(match['payments']) != 1: - simple_batch(batch) - batch = [] - for item in sorted(match['invoices'], key=material_date): - picture(slide(), item, (40, 25, 1200, 670)) - payments = sorted(match['payments'], key=material_date) - for index in range(0, len(payments), 4): - payment_page(payments[index:index + 4]) - if requires_photo(match): - photos(len(payments)) - else: - batch.append(match) - if len(batch) == 2: - simple_batch(batch) - batch = [] - simple_batch(batch) + invoices = sorted(match.get('invoices', []), key=material_date) + payments = sorted(match.get('payments', []), key=material_date) + if not invoices and not payments: + raise ValueError('所选组没有发票或付款截图,无法导出') + if len(invoices) == 1 and len(payments) == 1: + combined_page(invoices[0], payments, inline_photo=True) + continue + paired_count = len(invoices) - len(invoices) % 2 + for offset in range(0, paired_count, 2): + page = slide() + picture(page, invoices[offset], (48, 44, 624, 408), 'invoice') + picture(page, invoices[offset + 1], (48, 508, 624, 408), 'invoice') + if len(invoices) % 2: + combined_page(invoices[-1], payments[:2]) + payments = payments[2:] + payment_pages(payments) + photo_hint(slide(), (48, 48, 624, 864)) presentation.save(destination) + return dict(pptLayout='portrait-receipts-v1', pptSlideCount=len(presentation.slides)) def export_travel(state, destination): diff --git a/native-engine/tests/test_approved_ppt.py b/native-engine/tests/test_approved_ppt.py index bfd3839..86c48f1 100644 --- a/native-engine/tests/test_approved_ppt.py +++ b/native-engine/tests/test_approved_ppt.py @@ -1,4 +1,5 @@ import copy +import io import json import os import subprocess @@ -65,7 +66,12 @@ class ApprovedPPTTests(unittest.TestCase): second = Image.new('RGB', (300, 200), 'blue') first.save(pdf, save_all=True, append_images=[second]) 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): 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[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['materials'] = [self.material('invoice'), self.material('payment')] 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')]) other = copy.deepcopy(self.record) 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): destination = self.root / 'original.pptx' @@ -135,7 +141,10 @@ class ApprovedPPTTests(unittest.TestCase): events = [json.loads(line) for line in process.stdout.splitlines()] self.assertEqual(events[-1]['event'], 'result') 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__': diff --git a/native-engine/tests/test_exports.py b/native-engine/tests/test_exports.py index fbfcbd2..9722724 100644 --- a/native-engine/tests/test_exports.py +++ b/native-engine/tests/test_exports.py @@ -34,21 +34,49 @@ class ExportTests(unittest.TestCase): destination = self.root / 'test.pptx' export_ppt(self.state, destination, True) deck = Presentation(destination) - self.assertEqual(len(deck.slides), 3) - self.assertEqual(deck.slide_width, 1280 * 12700) - self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[2].shapes if shape.has_text_frame)) + self.assertEqual(len(deck.slides), 1) + self.assertEqual(deck.slide_width, 720 * 12700) + 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'] = '交通' destination = self.root / 'test.pptx' 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): self.state['matches'][0]['payments'] *= 5 destination = self.root / 'test.pptx' 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): destination = self.root / 'expense.xlsx' diff --git a/native-engine/tests/test_ppt_portrait.py b/native-engine/tests/test_ppt_portrait.py new file mode 100644 index 0000000..283ebd0 --- /dev/null +++ b/native-engine/tests/test_ppt_portrait.py @@ -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() diff --git a/reimburse/AdminPPTExport.swift b/reimburse/AdminPPTExport.swift index e3ed5ba..11229a7 100644 --- a/reimburse/AdminPPTExport.swift +++ b/reimburse/AdminPPTExport.swift @@ -203,7 +203,7 @@ struct AdminPPTExportSheet: View { Spacer() Button("关闭") { dismiss() }.disabled(exporter.busy) } - Text("与本地贴票使用相同 PPT 格式:发票大图、付款截图拼页、实物照片手动粘贴区;PDF 仅使用首页。不添加封面、归属页、备注或页脚,不自动贴入照片和其他材料。") + Text("与本地贴票使用相同竖版 3:4 格式:单票上方、付款下方;多张发票每页上下排两张,付款续页最多六张。单票单付右下留照片区,其他情况另留照片页。PDF 仅使用首页,不自动贴入实物照片或其他材料。") .font(.callout).foregroundStyle(.secondary) Text("导出范围:\(scopeText)").font(.callout.weight(.medium)) HStack { diff --git a/reimburse/MatchedPage.swift b/reimburse/MatchedPage.swift index 6a1adf5..8bd3eda 100644 --- a/reimburse/MatchedPage.swift +++ b/reimburse/MatchedPage.swift @@ -60,6 +60,8 @@ struct MatchedPage: View { }.font(.callout) Text("切换分组保留勾选;PPT 和个人报销单仅导出所选材料,报销单按费用类型合并。行程 Excel 仍使用全部已核对材料。") .font(.caption).foregroundStyle(.secondary) + Text("PPT 使用竖版 3:4:每页最多两张发票,付款续页最多六张,实物照片留空供手动粘贴。") + .font(.caption).foregroundStyle(.secondary) } }.padding(28).disabled(store.busy) .onChange(of: store.state.matches.map(\.id)) { _, identifiers in diff --git a/reimburse/NativeDiagnostics.swift b/reimburse/NativeDiagnostics.swift index d259568..0fcb5af 100644 --- a/reimburse/NativeDiagnostics.swift +++ b/reimburse/NativeDiagnostics.swift @@ -88,6 +88,28 @@ enum NativeDiagnostics { try ExpenseSummary.validateExportResult(groupedResult, matches: groupedState.matches) report["expenseCategoryGrouping"] = "passed" 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")) report["result"] = "passed" report["directory"] = root.path