1
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。
|
||||
|
||||
导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个 PPT 排版函数:1280×720,发票大图、一对一申请两笔一批拼付款截图、多对多付款截图每页最多四张,非交通/住宿保留实物照片手动粘贴占位页。交通差旅映射为本地“交通”、住宿费用映射为“住宿”。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或其他文字。同组同人员连续申请沿用本地合页规则,跨组或跨账户时结束当前批次;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。
|
||||
导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个竖版 3:4 排版函数(720×960 pt):单票单付同页,右下预留实物照片区;多发票每页最多两张上下排列,剩余单张发票可与前两张付款同页,付款续页为三列两行、最多六张。复杂组最后追加一张手工照片页,所有类别均预留照片位置。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或贴票类型标题。每笔申请独立排版,不与其他申请合页;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。
|
||||
|
||||
可取消、失败后保留选择、成功后在 Finder 中显示文件。中间文件使用独立临时目录并清理,完成后才原子写入所选位置;不改动本地贴票工作区,不执行 OCR 自动匹配,也不改变报销状态。原本地贴票导出逻辑保持不变。
|
||||
|
||||
|
||||
@@ -74,7 +74,11 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
||||
- 页面发票汇总、OCR 首金额和个人报销单最大金额的不同计算口径沿用旧代码,未擅自合并。
|
||||
- 订单截图与同金额支付凭证去重沿用旧规则,因此仍需人工检查不同订单同金额的情况。
|
||||
- “按类型分类”PPT 沿用先日期、后类别排序,并非重新设计分类封面或类别分区。
|
||||
- 实物照片不自动填入 PPT;除交通、住宿外保留手动粘贴占位页。
|
||||
- PPT 使用竖版 3:4(720×960 pt),纯白页面,不加封面或贴票类型标题。所有类别(包括交通、住宿)均预留实物照片位置,不自动填入实物照片。
|
||||
- 一张发票配一张付款截图:发票上方整幅,付款截图左下,右下留可编辑、可删除的实物照片提示,不另加照片页。
|
||||
- 一张发票配多张付款截图:首张发票页下方左右最多放两张付款,剩余付款按每页三列两行、最多六张续页,最后加一张手工照片页。
|
||||
- 多张发票:先按每页两张上下排列;若剩一张,放在下一页上方,下方容纳前两张付款。剩余付款每页最多六张,最后加一张手工照片页。只有发票或只有付款的组也能完整导出并预留照片页。
|
||||
- 不同核对组或不同审批申请不合页、不按比例猜测发票付款对应关系;原图等比缩放,不裁切、不重复、不漏图,PDF 仍只用首页。
|
||||
- 个人报销单沿用原始 XLSX 模板,直接修改工作表 XML,保留样式、打印设置、公式和关联资源;每页 13 条明细,最多六个签字岗位。删除全部岗位时按旧规则回退默认岗位。
|
||||
|
||||
### 必须说明的环境差异
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
+66
-68
@@ -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
|
||||
with image_for(item) as image:
|
||||
scale = min(width / image.width, height / image.height)
|
||||
actual_width, actual_height = int(image.width * scale), int(image.height * scale)
|
||||
content = io.BytesIO()
|
||||
actual_width, actual_height = image.width * scale, image.height * scale
|
||||
with io.BytesIO() as content:
|
||||
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))
|
||||
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()
|
||||
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)
|
||||
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 requires_photo(match):
|
||||
return match['category'].strip() not in ['交通', '住宿']
|
||||
def payment_pages(payments):
|
||||
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):
|
||||
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):
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user