1
This commit is contained in:
@@ -36,7 +36,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
||||
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
|
||||
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
||||
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
|
||||
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。
|
||||
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出 PPT。“全部”页可全选所有已核对组。行程 Excel 和个人报销单仍使用全部已核对材料。自动核对结果不代表已人工复核。
|
||||
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
|
||||
|
||||
## 功能对应
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct MatchedPPTSelection {
|
||||
var ids: Set<String> = []
|
||||
|
||||
func containsAll(_ matches: [MatchGroup]) -> Bool {
|
||||
!matches.isEmpty && matches.allSatisfy { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
mutating func setVisible(_ matches: [MatchGroup], selected: Bool) {
|
||||
let visible = Set(matches.map(\.id))
|
||||
if selected { ids.formUnion(visible) }
|
||||
else { ids.subtract(visible) }
|
||||
}
|
||||
|
||||
mutating func retain(_ identifiers: [String]) {
|
||||
ids.formIntersection(identifiers)
|
||||
}
|
||||
|
||||
func exportWorkspace(from workspace: Workspace) throws -> Workspace {
|
||||
guard !ids.isEmpty else {
|
||||
throw SelectionFailure(message: "请先勾选要导出的已核对材料。")
|
||||
}
|
||||
guard ids.isSubset(of: Set(workspace.matches.map(\.id))) else {
|
||||
throw SelectionFailure(message: "部分勾选材料已撤销或被替换,请重新选择后导出。")
|
||||
}
|
||||
var result = workspace
|
||||
result.matches = workspace.matches.filter { ids.contains($0.id) }
|
||||
result.invoices = result.matches.flatMap(\.invoices)
|
||||
result.payments = result.matches.flatMap(\.payments)
|
||||
result.photos = []
|
||||
result.directoryPaymentTotal = result.paymentTotal
|
||||
return result
|
||||
}
|
||||
|
||||
private struct SelectionFailure: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
}
|
||||
+140
-47
@@ -2,87 +2,180 @@ import SwiftUI
|
||||
|
||||
struct MatchedPage: View {
|
||||
@EnvironmentObject var store: WorkspaceStore
|
||||
@State private var selectedGroup = "全部"
|
||||
@State private var pptSelection = MatchedPPTSelection()
|
||||
|
||||
private var selectedMatches: [MatchGroup] {
|
||||
store.state.matches.filter { pptSelection.ids.contains($0.id) }
|
||||
}
|
||||
|
||||
private var groups: [String] {
|
||||
["全部"] + Array(Set(store.state.matches.map(\.category))).sorted()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
HStack {
|
||||
Picker("排版", selection: $store.classified) {
|
||||
Text("按类型分类").tag(true)
|
||||
Text("不分类").tag(false)
|
||||
}.pickerStyle(.segmented).frame(width: 200)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("已核对材料").font(.title2.bold())
|
||||
Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0)
|
||||
Button(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty)
|
||||
Button { store.export(.ppt) } label: { Label("导出 PPT", systemImage: "square.and.arrow.up") }
|
||||
.buttonStyle(.borderedProminent).disabled(store.state.matches.isEmpty)
|
||||
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
|
||||
Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty)
|
||||
}
|
||||
if store.state.matches.isEmpty {
|
||||
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
|
||||
} else {
|
||||
Picker("分组", selection: $selectedGroup) {
|
||||
ForEach(groups, id: \.self) { group in
|
||||
Text(group == "全部" ? "全部(\(store.state.matches.count))" : "\(group)(\(count(for: group)))")
|
||||
.tag(group)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: groups) { _, available in
|
||||
if !available.contains(selectedGroup) { selectedGroup = "全部" }
|
||||
}
|
||||
MatchGroupPage(
|
||||
title: selectedGroup,
|
||||
matches: matches(for: selectedGroup),
|
||||
baseIndex: baseIndex(for: selectedGroup),
|
||||
classified: $store.classified,
|
||||
selection: $pptSelection
|
||||
)
|
||||
HStack {
|
||||
Text("按发票开票日期排列 · 自动匹配结果也计入已核对").font(.caption).foregroundStyle(.secondary)
|
||||
Text("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))")
|
||||
.monospacedDigit()
|
||||
Spacer()
|
||||
Text("\(store.state.matches.count) 组").font(.caption.monospacedDigit())
|
||||
}
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 18) {
|
||||
ForEach(Array(store.state.sortedMatches.enumerated()), id: \.element.id) { index, match in
|
||||
MatchRow(match: match, index: index + 1)
|
||||
}
|
||||
}.padding(2)
|
||||
}
|
||||
Text("其中 \(selectedMatches.filter { selectedGroup != "全部" && $0.category != selectedGroup }.count) 组在其他分组")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("清空选择") { pptSelection.ids.removeAll() }.disabled(pptSelection.ids.isEmpty)
|
||||
}.font(.callout)
|
||||
Text("切换分组保留勾选;PPT 仅导出所选组的完整发票与付款材料。行程 Excel 和个人报销单仍使用全部已核对材料。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}.padding(28).disabled(store.busy)
|
||||
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in
|
||||
pptSelection.retain(identifiers)
|
||||
}
|
||||
}
|
||||
|
||||
private func matches(for group: String) -> [MatchGroup] {
|
||||
let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group }
|
||||
return matches
|
||||
}
|
||||
|
||||
private func count(for group: String) -> Int {
|
||||
matches(for: group).count
|
||||
}
|
||||
|
||||
private func baseIndex(for group: String) -> Int {
|
||||
guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 }
|
||||
return first + 1
|
||||
}
|
||||
}
|
||||
|
||||
struct MatchRow: View {
|
||||
struct MatchGroupPage: View {
|
||||
@EnvironmentObject var store: WorkspaceStore
|
||||
let match: MatchGroup
|
||||
let index: Int
|
||||
let title: String
|
||||
let matches: [MatchGroup]
|
||||
let baseIndex: Int
|
||||
@Binding var classified: Bool
|
||||
@Binding var selection: MatchedPPTSelection
|
||||
|
||||
var body: some View {
|
||||
Panel {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(String(format: "%02d", index)).font(.title2.monospacedDigit().bold()).foregroundStyle(.teal)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(match.invoices.count) 张发票 · \(match.payments.count) 张付款").font(.headline)
|
||||
Text("发票 \(currency(match.invoiceTotal)) / 付款 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Toggle("全选当前分组", isOn: Binding(
|
||||
get: { selection.containsAll(matches) },
|
||||
set: { selection.setVisible(matches, selected: $0) }
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
Text(title).font(.headline)
|
||||
Text("\(matches.count) 组 · 已选 \(matches.filter { selection.ids.contains($0.id) }.count)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("按发票日期排列").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if matches.isEmpty {
|
||||
EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。")
|
||||
} else {
|
||||
Table(matches) {
|
||||
TableColumn("选择") { match in
|
||||
Toggle("选择第 \(index(of: match)) 组", isOn: Binding(
|
||||
get: { selection.ids.contains(match.id) },
|
||||
set: { selected in
|
||||
if selected { selection.ids.insert(match.id) }
|
||||
else { selection.ids.remove(match.id) }
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox).labelsHidden()
|
||||
}.width(48)
|
||||
TableColumn("序号") { match in
|
||||
Text(String(format: "%02d", index(of: match))).monospacedDigit().foregroundStyle(.secondary)
|
||||
}.width(min: 48, ideal: 56, max: 64)
|
||||
TableColumn("发票") { match in
|
||||
MaterialCell(items: match.invoices, store: store)
|
||||
}.width(min: 170, ideal: 235)
|
||||
TableColumn("付款截图") { match in
|
||||
MaterialCell(items: match.payments, store: store)
|
||||
}.width(min: 170, ideal: 235)
|
||||
TableColumn("金额") { match in
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)).monospacedDigit()
|
||||
Text("付 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}.width(min: 115, ideal: 135)
|
||||
TableColumn("日期") { match in
|
||||
Text(match.date == "9999-12-31" ? "未识别" : match.date).monospacedDigit()
|
||||
}.width(min: 92, ideal: 105)
|
||||
TableColumn("匹配") { match in
|
||||
Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认")
|
||||
.font(.caption.weight(.medium)).padding(.horizontal, 10).padding(.vertical, 5)
|
||||
.background((match.matchType == "auto" ? Color.teal : Color.blue).opacity(0.1), in: Capsule())
|
||||
if store.classified {
|
||||
.foregroundStyle(match.matchType == "auto" ? .teal : .blue)
|
||||
}.width(min: 90, ideal: 105)
|
||||
TableColumn("操作") { match in
|
||||
HStack(spacing: 7) {
|
||||
if classified {
|
||||
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
|
||||
ForEach(expenseCategories, id: \.self) { Text($0) }
|
||||
}.labelsHidden().frame(width: 110)
|
||||
}.labelsHidden().frame(width: 95)
|
||||
}
|
||||
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }.help("撤销本组配对")
|
||||
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }
|
||||
.buttonStyle(.borderless).help("撤销本组配对")
|
||||
}
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
thumbnails(match.invoices.sorted { $0.issueDate < $1.issueDate })
|
||||
Image(systemName: "link").foregroundStyle(.teal).padding(.top, 50)
|
||||
thumbnails(match.payments.sorted { $0.sortDate < $1.sortDate })
|
||||
}.width(min: classified ? 135 : 48, ideal: classified ? 145 : 60)
|
||||
}
|
||||
Text(match.reasons.joined(separator: " · ")).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
|
||||
.frame(minHeight: 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func thumbnails(_ items: [Material]) -> some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(items) { item in
|
||||
Button { store.preview = item } label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
MaterialThumbnail(item: item, height: 115)
|
||||
Text(item.name).font(.caption).lineLimit(2).frame(height: 32, alignment: .topLeading)
|
||||
}.frame(width: 155)
|
||||
}.buttonStyle(.plain).help("点击放大 \(item.name)")
|
||||
private func index(of match: MatchGroup) -> Int {
|
||||
guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex }
|
||||
return baseIndex + index
|
||||
}
|
||||
}
|
||||
|
||||
struct MaterialCell: View {
|
||||
let items: [Material]
|
||||
@ObservedObject var store: WorkspaceStore
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("\(items.count) 份").font(.caption.weight(.medium))
|
||||
ForEach(items.prefix(2)) { item in
|
||||
Button(item.name) { store.preview = item }
|
||||
.buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)")
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if items.count > 2 {
|
||||
Text("还有 \(items.count - 2) 份").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,8 +215,19 @@ final class WorkspaceStore: ObservableObject {
|
||||
showExpense = true
|
||||
}
|
||||
|
||||
func export(_ kind: ExportKind) {
|
||||
func export(_ kind: ExportKind, selectedMatchIDs: Set<String>? = nil) {
|
||||
guard !busy, !state.matches.isEmpty else { return }
|
||||
let exportState: Workspace
|
||||
do {
|
||||
if kind == .ppt, let selectedMatchIDs {
|
||||
exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state)
|
||||
} else {
|
||||
exportState = state
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data]
|
||||
panel.canCreateDirectories = true
|
||||
@@ -236,7 +247,7 @@ final class WorkspaceStore: ObservableObject {
|
||||
try? FileManager.default.removeItem(at: temporary)
|
||||
}
|
||||
do {
|
||||
_ = try await run(["operation": kind.rawValue, "state": try jsonObject(state), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures])
|
||||
_ = try await run(["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures])
|
||||
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
||||
if kind == .expense { showExpense = false }
|
||||
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct MatchedPPTSelectionTests {
|
||||
static func match(_ id: String, category: String) -> MatchGroup {
|
||||
MatchGroup(id: id, invoices: [material(id + "-invoice", type: "invoice")],
|
||||
payments: [material(id + "-payment", type: "payment"), material(id + "-payment-2", type: "payment")],
|
||||
category: category, matchType: "manual", score: 100, reasons: [])
|
||||
}
|
||||
|
||||
static func material(_ id: String, type: String) -> Material {
|
||||
Material(id: id, name: id + ".png", path: "/tmp/" + id + ".png", type: type, size: 1,
|
||||
matched: true, previewPath: "", ocr: OCRData(), displayAmount: 10,
|
||||
sortDate: "2026-09-16", issueDate: "2026-09-16")
|
||||
}
|
||||
|
||||
static func main() throws {
|
||||
let meal = match("meal", category: "餐饮")
|
||||
let travel = match("travel", category: "交通")
|
||||
let hotel = match("hotel", category: "住宿")
|
||||
var workspace = Workspace()
|
||||
workspace.matches = [meal, travel, hotel]
|
||||
workspace.invoices = workspace.matches.flatMap(\.invoices)
|
||||
workspace.payments = workspace.matches.flatMap(\.payments)
|
||||
var selection = MatchedPPTSelection()
|
||||
precondition(!selection.containsAll([]))
|
||||
precondition(!selection.containsAll(workspace.matches))
|
||||
precondition((try? selection.exportWorkspace(from: workspace)) == nil)
|
||||
selection.setVisible([meal], selected: true)
|
||||
selection.setVisible([travel], selected: true)
|
||||
precondition(selection.ids == ["meal", "travel"])
|
||||
precondition(selection.containsAll([meal]))
|
||||
precondition(!selection.containsAll(workspace.matches))
|
||||
let exported = try selection.exportWorkspace(from: workspace)
|
||||
precondition(exported.matches.map(\.id) == ["meal", "travel"])
|
||||
precondition(exported.invoices.map(\.id) == ["meal-invoice", "travel-invoice"])
|
||||
precondition(exported.payments.count == 4)
|
||||
precondition(exported.matches[0].payments.count == 2)
|
||||
let payload = try JSONDecoder().decode(Workspace.self, from: JSONEncoder().encode(exported))
|
||||
precondition(payload.matches.map(\.id) == ["meal", "travel"])
|
||||
precondition(!payload.invoices.contains { $0.id.hasPrefix("hotel") })
|
||||
precondition(workspace.matches.count == 3)
|
||||
precondition(workspace.invoices.count == 3)
|
||||
selection.setVisible([meal], selected: false)
|
||||
precondition(selection.ids == ["travel"])
|
||||
let single = try selection.exportWorkspace(from: workspace)
|
||||
precondition(single.matches.map(\.id) == ["travel"])
|
||||
selection.setVisible(workspace.matches, selected: true)
|
||||
precondition(selection.containsAll(workspace.matches))
|
||||
let all = try selection.exportWorkspace(from: workspace)
|
||||
precondition(all.matches.map(\.id) == workspace.matches.map(\.id))
|
||||
selection.setVisible(workspace.matches, selected: false)
|
||||
precondition(selection.ids.isEmpty)
|
||||
selection.ids = ["removed", "meal"]
|
||||
precondition((try? selection.exportWorkspace(from: workspace)) == nil)
|
||||
selection.retain(workspace.matches.map(\.id))
|
||||
precondition(selection.ids == ["meal"])
|
||||
selection.retain([])
|
||||
precondition(selection.ids.isEmpty)
|
||||
print("Matched PPT selection tests passed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user