1
This commit is contained in:
@@ -86,12 +86,12 @@ def dispatch(request):
|
||||
state = request['state']
|
||||
if operation == 'refresh':
|
||||
return enrich(state)
|
||||
if not state['matches']:
|
||||
if not (state.get('approvedRecords') if operation == 'approved-ppt' else state.get('matches')):
|
||||
raise ValueError('至少完成一组核对后才能导出')
|
||||
destination = Path(request['destination'])
|
||||
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
|
||||
try:
|
||||
if operation == 'ppt':
|
||||
if operation in ('ppt', 'approved-ppt'):
|
||||
export_ppt(state, temporary, request.get('classified', True))
|
||||
elif operation == 'travel':
|
||||
export_travel(state, temporary)
|
||||
|
||||
@@ -42,12 +42,13 @@ def export_ppt(state, destination, classified):
|
||||
|
||||
def payment_page(payments):
|
||||
page = slide()
|
||||
height = 660 if 'approvedRecords' in state else 680
|
||||
if len(payments) == 1:
|
||||
picture(page, payments[0], (300, 20, 680, 680))
|
||||
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, 680))
|
||||
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))
|
||||
@@ -83,6 +84,87 @@ def export_ppt(state, destination, classified):
|
||||
payment_page([match['payments'][0] for match in batch])
|
||||
photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
|
||||
|
||||
if 'approvedRecords' in state:
|
||||
import contextlib
|
||||
import tempfile
|
||||
import pypdfium2 as pdfium
|
||||
from pptx.enum.text import MSO_AUTO_SIZE
|
||||
|
||||
records = state['approvedRecords']
|
||||
if not records or len(records) > 100:
|
||||
raise ValueError('每批请选择 1 至 100 笔已批准申请')
|
||||
with tempfile.TemporaryDirectory(prefix='approved-ppt-pages-', dir=Path(destination).parent) as directory:
|
||||
page_count = 0
|
||||
for record in records:
|
||||
summary = slide()
|
||||
label(summary, (55, 28, 1170, 64), '已批准报销 · #' + record['id'], 26, True)
|
||||
details = [record['projectName'] + ' / ' + record['team'],
|
||||
record['name'] + ' · ' + record['type'],
|
||||
'提交日期:' + record['date'] + ' 报销金额:¥' + record['amountText']]
|
||||
for index, detail in enumerate(details):
|
||||
label(summary, (55, 120 + index * 70, 1170, 60), detail, 22)
|
||||
note = record.get('note', '').strip()
|
||||
for offset in range(0, len(note), 240):
|
||||
note_page = summary if offset == 0 else slide()
|
||||
label(note_page, (55, 360 if offset == 0 else 100, 1170, 300),
|
||||
('备注:' if offset == 0 else '备注(续):') + note[offset:offset + 240], 18)
|
||||
groups = {}
|
||||
kind_names = {}
|
||||
for material in record['materials']:
|
||||
kind_names[material['kind']] = material.get('kindName', '其他材料')
|
||||
path = Path(material['path'])
|
||||
images = []
|
||||
try:
|
||||
if path.suffix.lower() == '.pdf':
|
||||
with contextlib.closing(pdfium.PdfDocument(str(path))) as document:
|
||||
if not 0 < len(document) <= 100:
|
||||
raise ValueError('单份 PDF 需为 1–100 页,请拆分后导出')
|
||||
for page_index in range(len(document)):
|
||||
with contextlib.closing(document[page_index]) as pdf_page:
|
||||
scale = min(140 / 72, 2600 / max(pdf_page.get_size()))
|
||||
with contextlib.closing(pdf_page.render(scale=scale)) as bitmap:
|
||||
preview = Path(directory) / f'{page_count}.png'
|
||||
bitmap.to_pil().convert('RGB').save(preview)
|
||||
images.append({'path': str(preview)})
|
||||
page_count += 1
|
||||
else:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
images.append(material)
|
||||
page_count += 1
|
||||
except Exception as error:
|
||||
raise ValueError(f"申请 #{record['id']} 的“{material['name']}”无法导出:{error}") from error
|
||||
if page_count > 1000:
|
||||
raise ValueError('材料展开后超过 1,000 页,请减少选择并分批导出')
|
||||
groups.setdefault(material['kind'], []).extend(images)
|
||||
if not groups:
|
||||
label(summary, (55, 660, 1170, 36), '该申请没有上传附件', 16)
|
||||
for item in groups.pop('invoice', []):
|
||||
picture(slide(), item, (40, 25, 1200, 670))
|
||||
payments = groups.pop('payment', [])
|
||||
for offset in range(0, len(payments), 4):
|
||||
payment_page(payments[offset:offset + 4])
|
||||
for kind, materials in sorted(groups.items()):
|
||||
for offset in range(0, len(materials), 4):
|
||||
page = slide()
|
||||
title = '实拍照片' if kind in ('receipt', 'photo') else kind_names[kind]
|
||||
label(page, (55, 20, 1170, 50), title, 20, True)
|
||||
batch = materials[offset:offset + 4]
|
||||
for index, item in enumerate(batch):
|
||||
box = (250, 90, 780, 570) if len(batch) == 1 else (55 + index % 2 * 610, 90 + index // 2 * 290, 560, 270)
|
||||
picture(page, item, box)
|
||||
start = list(presentation.slides).index(summary)
|
||||
for page in list(presentation.slides)[start + 1:]:
|
||||
label(page, (40, 696, 1200, 22),
|
||||
f"{record['projectName']} / {record['team']} · {record['name']} · {record['date']} · #{record['id']}", 10, True)
|
||||
for page in presentation.slides:
|
||||
for shape in page.shapes:
|
||||
if shape.has_text_frame:
|
||||
shape.text_frame.word_wrap = True
|
||||
shape.text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
||||
presentation.save(destination)
|
||||
return
|
||||
|
||||
matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
|
||||
batch = []
|
||||
for match in matches:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from pptx import Presentation
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from engine import dispatch
|
||||
from exports import export_ppt
|
||||
|
||||
|
||||
class ApprovedPPTTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.image = self.root / 'image.png'
|
||||
Image.new('RGB', (600, 400), 'white').save(self.image)
|
||||
self.record = dict(id='2', projectName='项目甲', team='摄影组', name='张三',
|
||||
date='2026-09-13', type='道具耗材', amountText='123.45', note='采购材料', materials=[])
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def material(self, kind, path=None):
|
||||
return dict(kind=kind, path=str(path or self.image), name='测试材料')
|
||||
|
||||
def export(self, records):
|
||||
destination = self.root / 'approved.pptx'
|
||||
dispatch(dict(operation='approved-ppt', state=dict(approvedRecords=records), destination=str(destination)))
|
||||
return Presentation(destination)
|
||||
|
||||
def test_all_materials_and_payment_pagination(self):
|
||||
self.record['materials'] = [self.material('invoice')] + [self.material('payment')] * 5 + [self.material('receipt'), self.material('custom')]
|
||||
deck = self.export([self.record])
|
||||
self.assertEqual(len(deck.slides), 6)
|
||||
pictures = [shape for page in deck.slides for shape in page.shapes if shape.shape_type == 13]
|
||||
self.assertEqual(len(pictures), 8)
|
||||
self.assertTrue(all('#2' in '\n'.join(shape.text for shape in page.shapes if shape.has_text_frame) for page in deck.slides))
|
||||
self.assertFalse(any('请手动粘贴' in shape.text for page in deck.slides for shape in page.shapes if shape.has_text_frame))
|
||||
|
||||
def test_multipage_pdf_exports_every_page(self):
|
||||
pdf = self.root / 'invoice.pdf'
|
||||
first = Image.new('RGB', (300, 200), 'white')
|
||||
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), 3)
|
||||
|
||||
def test_preserves_selected_order_without_merging_people(self):
|
||||
other = copy.deepcopy(self.record)
|
||||
other.update(id='1', name='李四', date='2026-09-01')
|
||||
self.record['materials'] = [self.material('payment')]
|
||||
other['materials'] = [self.material('payment')]
|
||||
deck = self.export([self.record, other])
|
||||
self.assertEqual(len(deck.slides), 4)
|
||||
self.assertIn('#2', deck.slides[0].shapes[0].text)
|
||||
self.assertIn('#1', deck.slides[2].shapes[0].text)
|
||||
|
||||
def test_empty_materials_and_long_note_not_silently_lost(self):
|
||||
self.record['note'] = '采购备注' * 250
|
||||
deck = self.export([self.record])
|
||||
text = '\n'.join(shape.text for page in deck.slides for shape in page.shapes if shape.has_text_frame)
|
||||
self.assertEqual(text.count('采购备注'), 250)
|
||||
self.assertIn('该申请没有上传附件', text)
|
||||
|
||||
def test_failure_keeps_existing_output(self):
|
||||
destination = self.root / 'original.pptx'
|
||||
destination.write_bytes(b'original')
|
||||
self.record['materials'] = [self.material('invoice', self.root / 'missing.pdf')]
|
||||
with self.assertRaisesRegex(ValueError, '申请 #2'):
|
||||
dispatch(dict(operation='approved-ppt', state=dict(approvedRecords=[self.record]), destination=str(destination)))
|
||||
self.assertEqual(destination.read_bytes(), b'original')
|
||||
self.assertEqual(list(self.root.glob('.*.pptx')), [])
|
||||
|
||||
def test_rejects_empty_and_excessive_batches(self):
|
||||
for records in ([], [self.record] * 101):
|
||||
with self.assertRaises(ValueError):
|
||||
export_ppt(dict(approvedRecords=records), self.root / 'invalid.pptx', False)
|
||||
|
||||
def test_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')]
|
||||
self.record['materials'] = [self.material('invoice'), self.material('payment')]
|
||||
destination = self.root / 'process.pptx'
|
||||
request = dict(operation='approved-ppt', state=dict(approvedRecords=[self.record]), 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)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user