diff --git a/PROJECT-ADMIN.md b/PROJECT-ADMIN.md index d2cfd0c..66ad8c7 100644 --- a/PROJECT-ADMIN.md +++ b/PROJECT-ADMIN.md @@ -1,5 +1,19 @@ # 管理端接入 +## 已批准报销导出 PPT + +报销审批页新增“导出 PPT”弹窗。自动分页加载当前项目/组别权限范围内的已批准申请,不局限于审批列表已加载的 100 条;支持按组别、账户和提交日期(含起止当天)筛选、逐条勾选、全选筛选结果及跨筛选累计选择。相同姓名通过账户 ID 区分。审批列表已有选择会带入弹窗,但待审核、退回的申请不会带入。 + +默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。 + +导出前重新查询每笔申请的管理权限、批准状态和版本,附件仍经原有鉴权下载接口获取。复用本地贴票的 1280×720 PPT 版式:每笔附归属信息页,发票逐页大图、付款凭证最多四张一页;已上传照片与自定义材料自动贴入,PDF 全页展开(单份最多 100 页、合计最多 1,000 个材料页,超过时拒绝并提示分批)。不同申请的凭证不混排;没有附件时明确注明。日期是提交日期,不是审批时间或票面时间。 + +可取消、失败后保留选择、成功后在 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、同名不同账户、跨组勾选、网络失败、取消及保存目录权限。 + ## 报销类型页面布局 报销类型页面采用左侧搜索/选择类型、右侧编辑规则的分栏布局。类型列表和规则区域分别滚动,底部保存栏始终可见;保存成功显示反馈,继续修改后恢复保存提示。新增类型固定在左下方,重命名/删除集中在详情标题的更多菜单中。 diff --git a/native-engine/engine.py b/native-engine/engine.py index a98335a..1ecdc0c 100644 --- a/native-engine/engine.py +++ b/native-engine/engine.py @@ -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) diff --git a/native-engine/exports.py b/native-engine/exports.py index 547eeaf..e1454d5 100644 --- a/native-engine/exports.py +++ b/native-engine/exports.py @@ -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: diff --git a/native-engine/tests/test_approved_ppt.py b/native-engine/tests/test_approved_ppt.py new file mode 100644 index 0000000..e13662b --- /dev/null +++ b/native-engine/tests/test_approved_ppt.py @@ -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() diff --git a/reimburse/AdminModels.swift b/reimburse/AdminModels.swift index adb8043..5a9ef60 100644 --- a/reimburse/AdminModels.swift +++ b/reimburse/AdminModels.swift @@ -71,6 +71,49 @@ struct AdminExpense: Codable, Identifiable { let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String let date: String; let version: Int; let files: [String: AdminAttachmentList]; let events: [AdminEvent] } + +enum AdminPPTOrder: String, CaseIterable, Identifiable { + case group = "组别 → 人员 → 时间" + case person = "人员 → 组别 → 时间" + case date = "时间 → 组别 → 人员" + var id: String { rawValue } + + func sorted(_ records: [AdminExpense]) -> [AdminExpense] { + records.sorted { left, right in + let leftGroup = [left.projectName, left.team, String(left.groupId)] + let rightGroup = [right.projectName, right.team, String(right.groupId)] + let leftPerson = [left.name, left.userId] + let rightPerson = [right.name, right.userId] + let leftKeys: [String] + let rightKeys: [String] + switch self { + case .group: + leftKeys = leftGroup + leftPerson + [left.date, left.id] + rightKeys = rightGroup + rightPerson + [right.date, right.id] + case .person: + leftKeys = leftPerson + leftGroup + [left.date, left.id] + rightKeys = rightPerson + rightGroup + [right.date, right.id] + case .date: + leftKeys = [left.date] + leftGroup + leftPerson + [left.id] + rightKeys = [right.date] + rightGroup + rightPerson + [right.id] + } + for (leftKey, rightKey) in zip(leftKeys, rightKeys) where leftKey != rightKey { + return leftKey.compare(rightKey, options: [.numeric], locale: Locale(identifier: "zh_CN")) == .orderedAscending + } + return false + } + } +} + +enum AdminPPTSelection { + static func filter(_ records: [AdminExpense], groupID: Int64, userID: String, start: String?, end: String?) -> [AdminExpense] { + records.filter { record in + record.state == "approved" && (groupID == 0 || record.groupId == groupID) && + (userID.isEmpty || record.userId == userID) && + (start.map { record.date >= $0 } ?? true) && (end.map { record.date <= $0 } ?? true) + } + } +} struct AdminExpenseMaterial: Codable, Identifiable, Equatable { let kind: String let name: String diff --git a/reimburse/AdminPPTExport.swift b/reimburse/AdminPPTExport.swift new file mode 100644 index 0000000..556bbfe --- /dev/null +++ b/reimburse/AdminPPTExport.swift @@ -0,0 +1,289 @@ +import SwiftUI +import Combine +import UniformTypeIdentifiers + +@MainActor final class AdminPPTExport: ObservableObject { + @Published var records: [AdminExpense] = [] + @Published var busy = false + @Published var status = "" + @Published var progress = 0.0 + @Published var error: String? + @Published var destination: URL? + private var task: Task? + private let engine = EngineBridge() + + func cancel() { + task?.cancel() + engine.cancel() + } + + func load(_ model: AdminStore) { + guard !busy else { return } + busy = true; error = nil; progress = 0; destination = nil + let projectID = model.projectID + let groupID = model.groupID + let origin = model.origin + let token = model.session?.token + task = Task { + defer { busy = false } + do { + var all: [AdminExpense] = [] + var before = "0" + var seen: Set = [] + while true { + try Task.checkCancellation() + status = "正在加载已批准申请(已检查 \(seen.count) 笔)…" + let page: [AdminExpense] = try await model.request("/api/admin/expenses?groupId=\(groupID)&before=\(before)") + guard model.origin == origin, model.session?.token == token else { throw CancellationError() } + guard page.allSatisfy({ !seen.contains($0.id) }) else { + throw AdminFailure(message: "分页数据异常,请刷新后重试") + } + seen.formUnion(page.map(\.id)) + all += page.filter { $0.state == "approved" && (projectID == 0 || $0.projectId == projectID) } + if page.count < 100 { break } + guard seen.count < 10000, let last = page.last else { + throw AdminFailure(message: "当前范围超过 10,000 笔申请,请关闭弹窗并选择具体组别后重试") + } + before = last.id + } + try Task.checkCancellation() + records = all + status = "已加载当前范围内全部已批准申请,共 \(all.count) 笔" + } catch is CancellationError { + status = "已取消加载,可重新加载" + } catch { records = []; self.error = error.localizedDescription } + } + } + + func export(_ selected: [AdminExpense], model: AdminStore) { + guard !busy, !selected.isEmpty, selected.count <= 100 else { return } + busy = true; error = nil; destination = nil; progress = 0 + let origin = model.origin + let token = model.session?.token + task = Task { + defer { busy = false } + var temporary: URL? + defer { if let temporary { try? FileManager.default.removeItem(at: temporary) } } + do { + let panel = NSSavePanel() + panel.nameFieldStringValue = "已批准报销贴票-\(selected.count)笔.pptx" + panel.allowedContentTypes = [UTType(filenameExtension: "pptx") ?? .data] + guard await panel.begin() == .OK, let output = panel.url else { return } + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("approved-ppt-" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporary = directory + var prepared: [[String: Any]] = [] + var bytes = 0 + for (index, record) in selected.enumerated() { + try Task.checkCancellation() + guard model.origin == origin, model.session?.token == token else { throw CancellationError() } + status = "正在核验并下载 \(index + 1)/\(selected.count) · \(record.name) · #\(record.id)" + let access: AdminAccess = try await model.request("/api/access") + guard access.groups.contains(where: { $0.id == record.groupId && $0.canManage }) else { + throw AdminFailure(message: "申请 #\(record.id) 所属组的管理权限已变化,请重新加载") + } + let detail: AdminExpense = try await model.request("/api/expenses/\(record.id)") + guard detail.state == "approved", detail.version == record.version, + detail.groupId == record.groupId, detail.userId == record.userId else { + throw AdminFailure(message: "申请 #\(record.id) 状态已变化,请重新加载后选择") + } + var materials: [[String: Any]] = [] + let files = detail.files.keys.sorted().flatMap { detail.files[$0]?.files ?? [] } + for file in files { + try Task.checkCancellation() + guard file.size >= 0, file.size <= 10 * 1024 * 1024, + Int64(bytes) + file.size <= 200 * 1024 * 1024 else { + throw AdminFailure(message: "附件合计超过 200 MB 或单份超过 10 MB,请分批导出") + } + let data = try await model.fileData(file) + bytes += data.count + guard bytes <= 200 * 1024 * 1024 else { throw AdminFailure(message: "附件合计超过 200 MB,请分批导出") } + let pdf = file.mime == "application/pdf" + let path = directory.appendingPathComponent(UUID().uuidString + (pdf ? ".pdf" : ".png")) + if pdf { + try data.write(to: path, options: .atomic) + } else { + guard file.mime.hasPrefix("image/") else { + throw AdminFailure(message: "申请 #\(record.id) 的“\(file.name)”无法读取,请检查附件后重试") + } + let png = try await Task.detached(priority: .userInitiated) { + guard let bitmap = NSBitmapImageRep(data: data), + let converted = bitmap.representation(using: .png, properties: [:]) else { + throw AdminFailure(message: "图片“\(file.name)”无法读取,请检查附件后重试") + } + return converted + }.value + try Task.checkCancellation() + try png.write(to: path, options: .atomic) + } + materials.append(["path": path.path, "kind": file.kind, "name": file.name, + "kindName": model.uploadKinds.first { $0.kind == file.kind }?.name ?? "其他材料"]) + } + prepared.append(["id": detail.id, "projectName": detail.projectName, "team": detail.team, + "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 + } + try Task.checkCancellation() + status = "正在排版 PPT…" + let generated = directory.appendingPathComponent("approved.pptx") + let request = try JSONSerialization.data(withJSONObject: ["operation": "approved-ppt", "state": ["approvedRecords": prepared], "destination": generated.path]) + _ = try await engine.run(request: request) { [weak self] progress, message in + Task { @MainActor in + self?.progress = 0.75 + progress * 0.24 + self?.status = message + } + } + try Task.checkCancellation() + guard model.origin == origin, model.session?.token == token else { throw CancellationError() } + try Data(contentsOf: generated, options: .mappedIfSafe).write(to: output, options: .atomic) + destination = output; progress = 1; status = "已导出 \(selected.count) 笔已批准报销" + } catch is CancellationError { + status = "已取消导出,选择已保留" + } catch { + self.error = error.localizedDescription + status = "导出未完成,选择已保留;未写入不完整的 PPT" + } + } + } +} + +struct AdminPPTExportSheet: View { + @ObservedObject var model: AdminStore + let initialSelection: Set + @StateObject private var exporter = AdminPPTExport() + @Environment(\.dismiss) private var dismiss + @State private var selection: Set = [] + @State private var groupID: Int64 = 0 + @State private var userID = "" + @State private var search = "" + @State private var order: AdminPPTOrder = .group + @State private var limitDates = false + @State private var startDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() + @State private var endDate = Date() + + private func dateKey(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + private var invalidDates: Bool { limitDates && dateKey(startDate) > dateKey(endDate) } + private var filtered: [AdminExpense] { + guard !invalidDates else { return [] } + let records = AdminPPTSelection.filter(exporter.records, groupID: groupID, userID: userID, + start: limitDates ? dateKey(startDate) : nil, end: limitDates ? dateKey(endDate) : nil) + return order.sorted(records.filter { AdminDisplayFilter.matches(search, fields: [$0.name, $0.type, $0.id, $0.userId]) }) + } + private var selected: [AdminExpense] { order.sorted(exporter.records.filter { selection.contains($0.id) }) } + private var groupChoices: [AdminExpense] { + var seen: Set = [] + return AdminPPTOrder.group.sorted(exporter.records).filter { seen.insert($0.groupId).inserted } + } + private var people: [AdminExpense] { + var seen: Set = [] + return AdminPPTOrder.person.sorted(exporter.records.filter { groupID == 0 || $0.groupId == groupID }) + .filter { seen.insert($0.userId).inserted } + } + private var scopeText: String { + if let group = model.selectedGroup { return group.label } + return model.access?.projects.first { $0.id == model.projectID }?.name ?? "全部授权项目 / 组别" + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label("导出已批准报销 PPT", systemImage: "doc.richtext").font(.title2.bold()) + Spacer() + Button("关闭") { dismiss() }.disabled(exporter.busy) + } + Text("只导出已批准申请,逐笔保留归属信息。发票大图、付款凭证拼页;照片及其他材料自动贴入,多页 PDF 完整展开。") + .font(.callout).foregroundStyle(.secondary) + Text("导出范围:\(scopeText)").font(.callout.weight(.medium)) + HStack { + Picker("组别", selection: $groupID) { + Text("当前范围全部组").tag(Int64(0)) + ForEach(groupChoices) { Text("\($0.projectName) / \($0.team)").tag($0.groupId) } + } + Picker("人员", selection: $userID) { + Text("全部人员").tag("") + ForEach(people) { Text("\($0.name)(账户 \($0.userId))").tag($0.userId) } + } + }.disabled(exporter.busy) + HStack { + Toggle("按提交日期筛选", isOn: $limitDates) + if limitDates { + DatePicker("从", selection: $startDate, displayedComponents: .date) + DatePicker("至", selection: $endDate, displayedComponents: .date) + } + Spacer() + Picker("排序", selection: $order) { + ForEach(AdminPPTOrder.allCases) { Text($0.rawValue).tag($0) } + }.frame(width: 290) + }.disabled(exporter.busy) + if invalidDates { Text("开始日期不能晚于结束日期").foregroundStyle(.red) } + HStack { + TextField("搜索姓名、类型或申请编号", text: $search).textFieldStyle(.roundedBorder).frame(width: 235) + Button("全选筛选结果") { selection.formUnion(filtered.map(\.id)) }.disabled(filtered.isEmpty) + Button("取消当前筛选选择") { selection.subtract(filtered.map(\.id)) }.disabled(filtered.isEmpty) + Button("清空全部选择") { selection = [] }.disabled(selection.isEmpty) + Spacer() + Text("筛选结果 \(filtered.count) 笔 · 时间从早到晚").foregroundStyle(.secondary) + }.disabled(exporter.busy) + Table(filtered) { + TableColumn("选择") { record in + Toggle("选择申请 #\(record.id)", isOn: Binding(get: { selection.contains(record.id) }, set: { + if $0 { selection.insert(record.id) } else { selection.remove(record.id) } + })).labelsHidden() + }.width(45) + TableColumn("项目", value: \.projectName) + TableColumn("组别", value: \.team) + TableColumn("申请人", value: \.name) + TableColumn("类型", value: \.type) + TableColumn("金额") { Text("¥\($0.amountText)") } + TableColumn("提交日期", value: \.date) + TableColumn("编号", value: \.id).width(55) + }.frame(minHeight: 200).disabled(exporter.busy) + .overlay { + if filtered.isEmpty && !exporter.busy { + Text(exporter.records.isEmpty ? "当前范围暂无已批准申请" : "没有符合筛选条件的已批准申请") + .foregroundStyle(.secondary).allowsHitTesting(false) + } + } + Text("已选 \(selected.count) 笔 · 合计 ¥\(String(format: "%.2f", Double(selected.reduce(Int64(0)) { $0 + $1.amountCents }) / 100)) · 其中 \(selection.subtracting(Set(filtered.map(\.id))).count) 笔在当前筛选外") + .font(.callout.weight(.medium)) + Text("切换筛选保留勾选;按所选排序合并为一个 PPT。每批最多 100 笔、原附件合计 200 MB。日期按提交日期,不是审批或发票日期。") + .font(.caption).foregroundStyle(.secondary) + if selected.count > 100 { Text("已超过 100 笔,请减少选择或分批导出").foregroundStyle(.red) } + if let error = exporter.error { Text(error).foregroundStyle(.red).textSelection(.enabled) } + HStack { + if exporter.busy { + ProgressView().controlSize(.small) + Text(exporter.status).lineLimit(1) + Spacer() + Button("取消") { exporter.cancel() } + } else { + Text(exporter.status).foregroundStyle(.secondary) + Spacer() + if let destination = exporter.destination { + Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([destination]) } + } + Button("重新加载") { exporter.load(model) } + Button("导出所选 \(selected.count) 笔") { exporter.export(selected, model: model) } + .buttonStyle(.borderedProminent).disabled(selected.isEmpty || selected.count > 100 || invalidDates) + } + } + if exporter.busy && exporter.progress > 0 { ProgressView(value: exporter.progress) } + }.padding(24).frame(width: 1040, height: 700) + .interactiveDismissDisabled(exporter.busy) + .task { selection = initialSelection; exporter.load(model) } + .onDisappear { exporter.cancel() } + .onChange(of: groupID) { _, _ in userID = "" } + .onChange(of: exporter.records.map(\.id)) { _, ids in selection.formIntersection(Set(ids)) } + .onChange(of: exporter.busy) { _, busy in + if !busy { selection.formIntersection(Set(exporter.records.map(\.id))) } + } + } +} diff --git a/reimburse/AdminView.swift b/reimburse/AdminView.swift index a7d5701..7f65b12 100644 --- a/reimburse/AdminView.swift +++ b/reimburse/AdminView.swift @@ -19,6 +19,7 @@ struct AdminView: View { @State private var selectedGroups: Set = [] @State private var selectedExpenses: Set = [] @State private var showDateExport = false + @State private var showPPTExport = false @State private var exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() @State private var exportEndDate = Date() @State private var hours = 168 @@ -102,6 +103,9 @@ struct AdminView: View { .sheet(isPresented: $showDateExport) { dateExport } + .sheet(isPresented: $showPPTExport) { + AdminPPTExportSheet(model: model, initialSelection: selectedExpenses) + } .sheet(item: $renameTarget) { group in VStack(alignment: .leading, spacing: 16) { Text("重命名组别").font(.title3.bold()) @@ -296,6 +300,8 @@ struct AdminView: View { Text("已退回").tag("returned") }.frame(width: 180) Spacer() + Button { showPPTExport = true } label: { Label("导出 PPT", systemImage: "doc.richtext") } + .help("筛选已批准的申请,按组别、人员和提交日期批量导出贴票 PPT") Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") } } ZStack { diff --git a/tests/AdminPPTSelectionTests.swift b/tests/AdminPPTSelectionTests.swift new file mode 100644 index 0000000..a00b03c --- /dev/null +++ b/tests/AdminPPTSelectionTests.swift @@ -0,0 +1,27 @@ +import Foundation + +@main +struct AdminPPTSelectionTests { + static func record(_ id: String, user: String = "1", name: String = "张三", group: Int64 = 1, + date: String = "2026-09-13", state: String = "approved") -> AdminExpense { + AdminExpense(id: id, groupId: group, projectId: 1, userId: user, name: name, team: "组\(group)", + projectName: "项目", type: "餐饮费用", amountCents: 1234, amountText: "12.34", note: "", + status: "已批准", state: state, date: date, version: 1, files: [:], events: []) + } + + static func main() { + let records = [record("10"), record("2"), record("3", user: "2", group: 2, date: "2026-09-12"), + record("4", state: "pending"), record("5", state: "returned")] + let approved = AdminPPTSelection.filter(records, groupID: 0, userID: "", start: nil, end: nil) + precondition(approved.count == 3) + precondition(AdminPPTSelection.filter(records, groupID: 1, userID: "", start: nil, end: nil).count == 2) + precondition(AdminPPTSelection.filter(records, groupID: 0, userID: "2", start: nil, end: nil).map(\.id) == ["3"]) + precondition(AdminPPTSelection.filter(records, groupID: 0, userID: "", start: "2026-09-13", end: "2026-09-13").count == 2) + precondition(AdminPPTSelection.filter(records, groupID: 0, userID: "", start: "2026-09-14", end: "2026-09-13").isEmpty) + precondition(AdminPPTOrder.group.sorted(approved).map(\.id) == ["2", "10", "3"]) + precondition(AdminPPTOrder.date.sorted(approved).map(\.id) == ["3", "2", "10"]) + let sameName = [record("7", user: "2"), record("6", user: "1", group: 2)] + precondition(AdminPPTOrder.person.sorted(sameName).map(\.id) == ["6", "7"]) + print("PPT selection, approval filtering, date bounds and ordering tests passed") + } +}