1
This commit is contained in:
@@ -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、同名不同账户、跨组勾选、网络失败、取消及保存目录权限。
|
||||||
|
|
||||||
## 报销类型页面布局
|
## 报销类型页面布局
|
||||||
|
|
||||||
报销类型页面采用左侧搜索/选择类型、右侧编辑规则的分栏布局。类型列表和规则区域分别滚动,底部保存栏始终可见;保存成功显示反馈,继续修改后恢复保存提示。新增类型固定在左下方,重命名/删除集中在详情标题的更多菜单中。
|
报销类型页面采用左侧搜索/选择类型、右侧编辑规则的分栏布局。类型列表和规则区域分别滚动,底部保存栏始终可见;保存成功显示反馈,继续修改后恢复保存提示。新增类型固定在左下方,重命名/删除集中在详情标题的更多菜单中。
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ def dispatch(request):
|
|||||||
state = request['state']
|
state = request['state']
|
||||||
if operation == 'refresh':
|
if operation == 'refresh':
|
||||||
return enrich(state)
|
return enrich(state)
|
||||||
if not state['matches']:
|
if not (state.get('approvedRecords') if operation == 'approved-ppt' else state.get('matches')):
|
||||||
raise ValueError('至少完成一组核对后才能导出')
|
raise ValueError('至少完成一组核对后才能导出')
|
||||||
destination = Path(request['destination'])
|
destination = Path(request['destination'])
|
||||||
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
|
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
|
||||||
try:
|
try:
|
||||||
if operation == 'ppt':
|
if operation in ('ppt', 'approved-ppt'):
|
||||||
export_ppt(state, temporary, request.get('classified', True))
|
export_ppt(state, temporary, request.get('classified', True))
|
||||||
elif operation == 'travel':
|
elif operation == 'travel':
|
||||||
export_travel(state, temporary)
|
export_travel(state, temporary)
|
||||||
|
|||||||
@@ -42,12 +42,13 @@ def export_ppt(state, destination, classified):
|
|||||||
|
|
||||||
def payment_page(payments):
|
def payment_page(payments):
|
||||||
page = slide()
|
page = slide()
|
||||||
|
height = 660 if 'approvedRecords' in state else 680
|
||||||
if len(payments) == 1:
|
if len(payments) == 1:
|
||||||
picture(page, payments[0], (300, 20, 680, 680))
|
picture(page, payments[0], (300, 20, 680, height))
|
||||||
else:
|
else:
|
||||||
width = (1200 - 24 * (len(payments) - 1)) // len(payments)
|
width = (1200 - 24 * (len(payments) - 1)) // len(payments)
|
||||||
for index, item in enumerate(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):
|
def label(page, box, value, size, title=False):
|
||||||
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
|
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])
|
payment_page([match['payments'][0] for match in batch])
|
||||||
photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
|
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 = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
|
||||||
batch = []
|
batch = []
|
||||||
for match in matches:
|
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()
|
||||||
@@ -71,6 +71,49 @@ struct AdminExpense: Codable, Identifiable {
|
|||||||
let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String
|
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]
|
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 {
|
struct AdminExpenseMaterial: Codable, Identifiable, Equatable {
|
||||||
let kind: String
|
let kind: String
|
||||||
let name: String
|
let name: String
|
||||||
|
|||||||
@@ -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<Void, Never>?
|
||||||
|
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<String> = []
|
||||||
|
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<String>
|
||||||
|
@StateObject private var exporter = AdminPPTExport()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selection: Set<String> = []
|
||||||
|
@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<Int64> = []
|
||||||
|
return AdminPPTOrder.group.sorted(exporter.records).filter { seen.insert($0.groupId).inserted }
|
||||||
|
}
|
||||||
|
private var people: [AdminExpense] {
|
||||||
|
var seen: Set<String> = []
|
||||||
|
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))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ struct AdminView: View {
|
|||||||
@State private var selectedGroups: Set<Int64> = []
|
@State private var selectedGroups: Set<Int64> = []
|
||||||
@State private var selectedExpenses: Set<String> = []
|
@State private var selectedExpenses: Set<String> = []
|
||||||
@State private var showDateExport = false
|
@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 exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||||
@State private var exportEndDate = Date()
|
@State private var exportEndDate = Date()
|
||||||
@State private var hours = 168
|
@State private var hours = 168
|
||||||
@@ -102,6 +103,9 @@ struct AdminView: View {
|
|||||||
.sheet(isPresented: $showDateExport) {
|
.sheet(isPresented: $showDateExport) {
|
||||||
dateExport
|
dateExport
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showPPTExport) {
|
||||||
|
AdminPPTExportSheet(model: model, initialSelection: selectedExpenses)
|
||||||
|
}
|
||||||
.sheet(item: $renameTarget) { group in
|
.sheet(item: $renameTarget) { group in
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("重命名组别").font(.title3.bold())
|
Text("重命名组别").font(.title3.bold())
|
||||||
@@ -296,6 +300,8 @@ struct AdminView: View {
|
|||||||
Text("已退回").tag("returned")
|
Text("已退回").tag("returned")
|
||||||
}.frame(width: 180)
|
}.frame(width: 180)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button { showPPTExport = true } label: { Label("导出 PPT", systemImage: "doc.richtext") }
|
||||||
|
.help("筛选已批准的申请,按组别、人员和提交日期批量导出贴票 PPT")
|
||||||
Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") }
|
Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") }
|
||||||
}
|
}
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user