From 4b49b09230def165eccaca8fadcb44b4c08f7571 Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Sun, 13 Sep 2026 15:32:22 +0800 Subject: [PATCH] 1 --- PROJECT-ADMIN.md | 4 +- native-engine/exports.py | 133 +++++++++-------------- native-engine/tests/test_approved_ppt.py | 67 +++++++++--- reimburse/AdminPPTExport.swift | 8 +- 4 files changed, 111 insertions(+), 101 deletions(-) diff --git a/PROJECT-ADMIN.md b/PROJECT-ADMIN.md index 66ad8c7..559489c 100644 --- a/PROJECT-ADMIN.md +++ b/PROJECT-ADMIN.md @@ -6,13 +6,13 @@ 默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。 -导出前重新查询每笔申请的管理权限、批准状态和版本,附件仍经原有鉴权下载接口获取。复用本地贴票的 1280×720 PPT 版式:每笔附归属信息页,发票逐页大图、付款凭证最多四张一页;已上传照片与自定义材料自动贴入,PDF 全页展开(单份最多 100 页、合计最多 1,000 个材料页,超过时拒绝并提示分批)。不同申请的凭证不混排;没有附件时明确注明。日期是提交日期,不是审批时间或票面时间。 +导出前重新查询每笔申请的管理权限、批准状态和版本,发票、付款截图仍经原有鉴权下载接口获取。直接调用本地贴票的同一个 PPT 排版函数:1280×720,发票大图、一对一申请两笔一批拼付款截图、多对多付款截图每页最多四张,非交通/住宿保留实物照片手动粘贴占位页。交通差旅映射为本地“交通”、住宿费用映射为“住宿”。PDF 和本地一样仅使用首页;照片和其他材料不自动贴入。没有发票或付款截图的申请会提示取消选择,不静默遗漏。不增加封面、归属页、备注、页脚或其他文字。同组同人员连续申请沿用本地合页规则,跨组或跨账户时结束当前批次;整体顺序以弹窗所选排序为准。日期是提交日期,不是审批时间或票面时间。 可取消、失败后保留选择、成功后在 Finder 中显示文件。中间文件使用独立临时目录并清理,完成后才原子写入所选位置;不改动本地贴票工作区,不执行 OCR 自动匹配,也不改变报销状态。原本地贴票导出逻辑保持不变。 本功能不需改后端或迁移数据库。因新增本地引擎指令 `approved-ppt`,更新后必须先运行 `bash native-engine/build-engine.sh` 再重新构建 Mac 应用,不能只更新 Swift 界面而沿用旧引擎。 -回归验证:`.build-tools/bin/python -m unittest discover -s native-engine/tests -v`;使用匹配的 Xcode 工具链运行 `swiftc reimburse/AdminModels.swift tests/AdminPPTSelectionTests.swift -o /tmp/admin-ppt-selection-tests && /tmp/admin-ppt-selection-tests`。真机需验收多页 PDF、同名不同账户、跨组勾选、网络失败、取消及保存目录权限。 +回归验证:`.build-tools/bin/python -m unittest discover -s native-engine/tests -v`;使用匹配的 Xcode 工具链运行 `swiftc reimburse/AdminModels.swift tests/AdminPPTSelectionTests.swift -o /tmp/admin-ppt-selection-tests && /tmp/admin-ppt-selection-tests`。PPT 回归逐页比较本地与审批导出的幻灯片 XML、图片与几何布局。真机需验收 PDF 首页、同名不同账户、跨组勾选、网络失败、取消及保存目录权限。 ## 报销类型页面布局 diff --git a/native-engine/exports.py b/native-engine/exports.py index e1454d5..09d0209 100644 --- a/native-engine/exports.py +++ b/native-engine/exports.py @@ -17,7 +17,50 @@ def image_for(item): return Image.open(item.get('previewPath') or item['path']).convert('RGB') -def export_ppt(state, destination, classified): +def export_approved_ppt(records, destination, classified): + import contextlib + import tempfile + import pypdfium2 as pdfium + + if not records or len(records) > 100: + raise ValueError('每批请选择 1 至 100 笔已批准申请') + categories = {'交通差旅': '交通', '住宿费用': '住宿'} + with tempfile.TemporaryDirectory(prefix='approved-ppt-pages-', dir=Path(destination).parent) as directory: + matches = [] + for record in records: + match = dict(invoices=[], payments=[], category=categories.get(record['type'], record['type'])) + match['scope'] = (record.get('groupId', record['team']), record.get('userId', record['name'])) + for material in record['materials']: + if material['kind'] not in ('invoice', 'payment'): + continue + path = Path(material['path']) + try: + if path.suffix.lower() == '.pdf': + with contextlib.closing(pdfium.PdfDocument(str(path))) as document: + if not len(document): + raise ValueError('PDF 没有可预览页面') + with contextlib.closing(document[0]) as page: + with contextlib.closing(page.render(scale=140 / 72)) as bitmap: + preview = Path(directory) / f'{len(matches)}-{len(match["invoices"])}-{len(match["payments"])}.png' + bitmap.to_pil().convert('RGB').save(preview) + path = preview + else: + with Image.open(path) as image: + image.verify() + except Exception as error: + raise ValueError(f"申请 #{record['id']} 的“{material['name']}”无法导出:{error}") from error + item = dict(path=str(path), ocr=dict(rawText='', dates=[record['date']])) + match['invoices' if material['kind'] == 'invoice' else 'payments'].append(item) + 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) + + +def export_ppt(state, destination, classified, preserve_order=False): + if 'approvedRecords' in state: + return export_approved_ppt(state['approvedRecords'], destination, classified) + from pptx import Presentation from pptx.util import Pt from pptx.dml.color import RGBColor @@ -42,7 +85,7 @@ def export_ppt(state, destination, classified): def payment_page(payments): page = slide() - height = 660 if 'approvedRecords' in state else 680 + height = 680 if len(payments) == 1: picture(page, payments[0], (300, 20, 680, height)) else: @@ -84,90 +127,12 @@ 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 '')) + 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 = [] 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 = [] diff --git a/native-engine/tests/test_approved_ppt.py b/native-engine/tests/test_approved_ppt.py index e13662b..bfd3839 100644 --- a/native-engine/tests/test_approved_ppt.py +++ b/native-engine/tests/test_approved_ppt.py @@ -35,39 +35,80 @@ class ApprovedPPTTests(unittest.TestCase): dispatch(dict(operation='approved-ppt', state=dict(approvedRecords=records), destination=str(destination))) return Presentation(destination) + def assert_matches_local(self, deck, matches): + destination = self.root / 'local.pptx' + export_ppt(dict(matches=matches), destination, True) + local = Presentation(destination) + self.assertEqual(deck.slide_width, local.slide_width) + self.assertEqual(deck.slide_height, local.slide_height) + self.assertEqual(len(deck.slides), len(local.slides)) + for actual, expected in zip(deck.slides, local.slides): + self.assertEqual(actual._element.xml, expected._element.xml) + self.assertEqual([shape.image.blob for shape in actual.shapes if shape.shape_type == 13], + [shape.image.blob for shape in expected.shapes if shape.shape_type == 13]) + + def local_match(self, invoices=1, payments=1, category='道具耗材'): + item = dict(path=str(self.image), ocr=dict(rawText='', dates=['2026-09-13'])) + return dict(invoices=[item] * invoices, payments=[item] * payments, category=category) + 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) + self.assert_matches_local(deck, [self.local_match(payments=5)]) 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)) + self.assertEqual(len(pictures), 6) + self.assertTrue(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): + def test_multipage_pdf_uses_first_page_like_local(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) + self.assertEqual(len(self.export([self.record]).slides), 1) def test_preserves_selected_order_without_merging_people(self): other = copy.deepcopy(self.record) other.update(id='1', name='李四', date='2026-09-01') + blue = self.root / 'blue.png' + Image.new('RGB', (600, 400), 'blue').save(blue) self.record['materials'] = [self.material('payment')] - other['materials'] = [self.material('payment')] + other['materials'] = [self.material('payment', blue)] 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) + 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_empty_materials_and_long_note_not_silently_lost(self): + def test_no_added_metadata_and_same_simple_batch_layout(self): self.record['note'] = '采购备注' * 250 - deck = self.export([self.record]) + self.record['materials'] = [self.material('invoice'), self.material('payment')] + deck = self.export([self.record, copy.deepcopy(self.record)]) + self.assert_matches_local(deck, [self.local_match(), self.local_match()]) 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) + for value in ('采购备注', '张三', '项目甲', '已批准', '#2', '2026-09-13', '123.45'): + self.assertNotIn(value, text) + + def test_travel_and_accommodation_use_local_categories(self): + self.record['materials'] = [self.material('invoice'), self.material('payment')] + for remote, local in [('交通差旅', '交通'), ('住宿费用', '住宿'), ('交通', '交通')]: + self.record['type'] = remote + self.assert_matches_local(self.export([self.record]), [self.local_match(category=local)]) + + def test_multiple_invoices_use_local_layout(self): + self.record['materials'] = [self.material('invoice')] * 3 + [self.material('payment')] * 2 + self.assert_matches_local(self.export([self.record]), [self.local_match(invoices=3, payments=2)]) + + def test_empty_or_only_extra_materials_fail_without_extra_slides(self): + for materials in ([], [self.material('receipt')], [self.material('custom')]): + self.record['materials'] = materials + with self.assertRaisesRegex(ValueError, '没有发票或付款截图'): + self.export([self.record]) + + def test_same_name_different_accounts_do_not_share_payment_page(self): + 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) def test_failure_keeps_existing_output(self): destination = self.root / 'original.pptx' diff --git a/reimburse/AdminPPTExport.swift b/reimburse/AdminPPTExport.swift index 556bbfe..e3ed5ba 100644 --- a/reimburse/AdminPPTExport.swift +++ b/reimburse/AdminPPTExport.swift @@ -89,7 +89,10 @@ import UniformTypeIdentifiers throw AdminFailure(message: "申请 #\(record.id) 状态已变化,请重新加载后选择") } var materials: [[String: Any]] = [] - let files = detail.files.keys.sorted().flatMap { detail.files[$0]?.files ?? [] } + let files = ["invoice", "payment"].flatMap { detail.files[$0]?.files ?? [] } + guard !files.isEmpty else { + throw AdminFailure(message: "申请 #\(record.id) 没有发票或付款截图,无法按本地贴票格式导出,请取消选择该申请") + } for file in files { try Task.checkCancellation() guard file.size >= 0, file.size <= 10 * 1024 * 1024, @@ -121,6 +124,7 @@ import UniformTypeIdentifiers "kindName": model.uploadKinds.first { $0.kind == file.kind }?.name ?? "其他材料"]) } prepared.append(["id": detail.id, "projectName": detail.projectName, "team": detail.team, + "groupId": detail.groupId, "userId": detail.userId, "name": detail.name, "date": detail.date, "type": detail.type, "amountText": detail.amountText, "note": detail.note, "materials": materials]) progress = Double(index + 1) / Double(selected.count) * 0.75 @@ -199,7 +203,7 @@ struct AdminPPTExportSheet: View { Spacer() Button("关闭") { dismiss() }.disabled(exporter.busy) } - Text("只导出已批准申请,逐笔保留归属信息。发票大图、付款凭证拼页;照片及其他材料自动贴入,多页 PDF 完整展开。") + Text("与本地贴票使用相同 PPT 格式:发票大图、付款截图拼页、实物照片手动粘贴区;PDF 仅使用首页。不添加封面、归属页、备注或页脚,不自动贴入照片和其他材料。") .font(.callout).foregroundStyle(.secondary) Text("导出范围:\(scopeText)").font(.callout.weight(.medium)) HStack {