From c2652b83c423d682ec1aa658637b7f161e82c39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 22 Jun 2026 17:03:27 +0800 Subject: [PATCH] Sync order detail and scanner verification --- suixinkan.xcodeproj/project.pbxproj | 2 + .../App/Navigation/NavigationRouter.swift | 16 + suixinkan/Features/Orders/API/OrdersAPI.swift | 38 +- .../Features/Orders/Models/OrderModels.swift | 269 +++++++++++++- suixinkan/Features/Orders/Orders.md | 15 +- .../ViewModels/OrderDetailViewModel.swift | 71 ++++ .../Orders/ViewModels/OrdersViewModel.swift | 34 +- .../Orders/Views/OrderCodeScannerView.swift | 330 ++++++++++++++++++ .../Orders/Views/OrderDetailViews.swift | 314 +++++++++++++++++ .../Features/Orders/Views/OrdersView.swift | 148 ++++++-- .../Fixtures/store_order_detail_success.json | 42 +++ suixinkanTests/NavigationRouterTests.swift | 28 ++ .../OrderDetailViewModelTests.swift | 120 +++++++ suixinkanTests/OrderNumberParserTests.swift | 38 ++ suixinkanTests/OrdersViewModelTests.swift | 45 ++- 15 files changed, 1456 insertions(+), 54 deletions(-) create mode 100644 suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift create mode 100644 suixinkan/Features/Orders/Views/OrderCodeScannerView.swift create mode 100644 suixinkan/Features/Orders/Views/OrderDetailViews.swift create mode 100644 suixinkanTests/Fixtures/store_order_detail_success.json create mode 100644 suixinkanTests/OrderDetailViewModelTests.swift create mode 100644 suixinkanTests/OrderNumberParserTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 5b28eba..26db165 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -344,6 +344,7 @@ DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -381,6 +382,7 @@ DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index 9129b9d..fbdc62d 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -13,6 +13,7 @@ enum AppRoute: Hashable { case placeholder(title: String) case home(HomeRoute) case profile(ProfileRoute) + case orders(OrdersRoute) /// 返回该路由通过 NavigationStack push 后是否隐藏底部 TabBar。 var hidesTabBarWhenPushed: Bool { @@ -29,6 +30,21 @@ enum AppRoute: Hashable { route.destinationView case .profile(let route): route.destinationView + case .orders(let route): + route.destinationView + } + } +} + +extension OrdersRoute { + /// 构建订单模块二级路由对应的目标页面。 + @ViewBuilder + var destinationView: some View { + switch self { + case .storeDetail(let item): + StoreOrderDetailView(item: item) + case .writeOffDetail(let item): + WriteOffOrderDetailView(item: item) } } } diff --git a/suixinkan/Features/Orders/API/OrdersAPI.swift b/suixinkan/Features/Orders/API/OrdersAPI.swift index cf5d85e..2833f94 100644 --- a/suixinkan/Features/Orders/API/OrdersAPI.swift +++ b/suixinkan/Features/Orders/API/OrdersAPI.swift @@ -25,10 +25,13 @@ protocol OrderServing { ) async throws -> ListPayload /// 获取核销订单列表。 - func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload /// 核销指定订单号。 func writeOff(orderNumber: String) async throws + + /// 获取门店订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse } @MainActor @@ -85,16 +88,21 @@ final class OrdersAPI: OrderServing { } /// 获取核销订单列表。 - func writeOffList(scenicId: Int, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { - try await client.send( + func writeOffList(scenicId: Int, storeId: Int? = nil, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + var queryItems = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let storeId, storeId > 0 { + queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)")) + } + + return try await client.send( APIRequest( method: .get, path: "/api/yf-handset-app/photog/order/order-verification-list", - queryItems: [ - URLQueryItem(name: "scenic_id", value: "\(scenicId)"), - URLQueryItem(name: "page", value: "\(max(page, 1))"), - URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") - ] + queryItems: queryItems ) ) } @@ -109,4 +117,18 @@ final class OrdersAPI: OrderServing { ) ) } + + /// 获取门店订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/order/detail", + queryItems: [ + URLQueryItem(name: "store_id", value: "\(storeId)"), + URLQueryItem(name: "order_number", value: orderNumber) + ] + ) + ) + } } diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index 83e49ae..a02658d 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -32,7 +32,7 @@ enum OrderFilters { } /// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。 -struct OrderEntity: Decodable, Identifiable, Equatable { +struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { var id: String { orderNumber } let photogUid: Int @@ -124,7 +124,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable { } /// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。 -struct OrderPhotoTravel: Decodable, Equatable { +struct OrderPhotoTravel: Decodable, Equatable, Hashable { let orderPhotoNum: Int let orderVideoNum: Int let checkInTime: String @@ -151,7 +151,7 @@ struct OrderPhotoTravel: Decodable, Equatable { } /// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。 -struct WriteOffOrderItem: Decodable, Identifiable, Equatable { +struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable { var id: String { orderNumber } let orderNumber: String @@ -197,6 +197,255 @@ struct WriteOffRequest: Encodable { } } +/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。 +enum OrdersRoute: Hashable { + case storeDetail(OrderEntity) + case writeOffDetail(WriteOffOrderItem) +} + +/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。 +struct StoreOrderDetailResponse: Decodable, Equatable, Hashable { + let orderNumber: String + let orderType: Int + let orderTypeLabel: String + let createdAt: String + let orderStatus: Int + let orderStatusName: String + let actualPayAmount: String + let actualRefundAmount: String + let payTypeName: String + let payTime: String + let completeTime: String + let userId: Int + let phone: String + let projectId: Int + let projectName: String + let remark: String + let multiTravel: StoreOrderMultiTravelInfo? + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case orderType = "order_type" + case orderTypeLabel = "order_type_label" + case createdAt = "created_at" + case orderStatus = "order_status" + case orderStatusName = "order_status_name" + case actualPayAmount = "actual_pay_amount" + case actualRefundAmount = "actual_refund_amount" + case payTypeName = "pay_type_name" + case payTime = "pay_time" + case completeTime = "complete_time" + case userId = "user_id" + case phone + case projectId = "project_id" + case projectName = "project_name" + case remark + case multiTravel = "multi_travel" + } + + /// 宽松解码订单详情字段,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0 + orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel) + createdAt = try container.decodeLossyString(forKey: .createdAt) + orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0 + orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) + actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount) + actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount) + payTypeName = try container.decodeLossyString(forKey: .payTypeName) + payTime = try container.decodeLossyString(forKey: .payTime) + completeTime = try container.decodeLossyString(forKey: .completeTime) + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + phone = try container.decodeLossyString(forKey: .phone) + projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0 + projectName = try container.decodeLossyString(forKey: .projectName) + remark = try container.decodeLossyString(forKey: .remark) + multiTravel = try container.decodeIfPresent(StoreOrderMultiTravelInfo.self, forKey: .multiTravel) + } +} + +/// 多景点旅拍详情实体,表示项目配置和各拍摄点状态。 +struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable { + let projectInfo: StoreOrderProjectInfo? + let shootingList: [StoreOrderShootingListItem] + + enum CodingKeys: String, CodingKey { + case projectInfo = "project_info" + case shootingList = "shooting_list" + } + + /// 宽松解码多景点旅拍信息。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectInfo = try container.decodeIfPresent(StoreOrderProjectInfo.self, forKey: .projectInfo) + shootingList = try container.decodeIfPresent([StoreOrderShootingListItem].self, forKey: .shootingList) ?? [] + } +} + +/// 订单项目配置实体,表示打卡点数量和单点素材数量。 +struct StoreOrderProjectInfo: Decodable, Equatable, Hashable { + let settleSpotNum: Int + let singleSpotMaterialNum: Int + let singleSpotPhotoNum: Int + let singleSpotVideoNum: Int + + enum CodingKeys: String, CodingKey { + case settleSpotNum = "settle_spot_num" + case singleSpotMaterialNum = "single_spot_material_num" + case singleSpotPhotoNum = "single_spot_photo_num" + case singleSpotVideoNum = "single_spot_video_num" + } + + /// 宽松解码项目配置字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + settleSpotNum = try container.decodeLossyInt(forKey: .settleSpotNum) ?? 0 + singleSpotMaterialNum = try container.decodeLossyInt(forKey: .singleSpotMaterialNum) ?? 0 + singleSpotPhotoNum = try container.decodeLossyInt(forKey: .singleSpotPhotoNum) ?? 0 + singleSpotVideoNum = try container.decodeLossyInt(forKey: .singleSpotVideoNum) ?? 0 + } +} + +/// 拍摄点状态实体,表示订单在某个打卡点的拍摄人员和完成情况。 +struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable { + var id: String { "\(scenicSpotId)-\(photogUid)" } + + let scenicSpotId: Int + let photogUid: Int + let scenicSpotName: String + let staffName: String + let photogName: String + let status: Int + let startAvg: Double + let start: Double + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case photogUid = "photog_uid" + case scenicSpotName = "scenic_spot_name" + case staffName = "staff_name" + case photogName = "photog_name" + case status + case startAvg = "start_avg" + case start + } + + /// 宽松解码拍摄点状态字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0 + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + staffName = try container.decodeLossyString(forKey: .staffName) + photogName = try container.decodeLossyString(forKey: .photogName) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + startAvg = try container.decodeLossyDouble(forKey: .startAvg) ?? 0 + start = try container.decodeLossyDouble(forKey: .start) ?? 0 + } +} + +/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。 +struct StoreOrderDetailDisplay: Equatable { + let orderNumber: String + let orderTypeLabel: String + let createdAt: String + let orderStatusName: String + let actualPayAmount: String + let actualRefundAmount: String + let payTypeName: String + let payTime: String + let completeTime: String + let userId: Int + let phone: String + let projectId: Int + let projectName: String + let remark: String + + /// 使用订单列表项构建兜底展示数据。 + init(item: OrderEntity) { + orderNumber = item.orderNumber + orderTypeLabel = item.orderTypeLabel + createdAt = item.createdAt + orderStatusName = item.orderStatusName + actualPayAmount = item.actualPayAmount + actualRefundAmount = item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount + payTypeName = item.payTypeName + payTime = item.payTime + completeTime = item.completeTime + userId = item.userId + phone = item.phone + projectId = item.projectId + projectName = item.projectName + remark = item.remark + } + + /// 使用详情接口响应构建完整展示数据。 + init(detail: StoreOrderDetailResponse) { + orderNumber = detail.orderNumber + orderTypeLabel = detail.orderTypeLabel + createdAt = detail.createdAt + orderStatusName = detail.orderStatusName + actualPayAmount = detail.actualPayAmount + actualRefundAmount = detail.actualRefundAmount + payTypeName = detail.payTypeName + payTime = detail.payTime + completeTime = detail.completeTime + userId = detail.userId + phone = detail.phone + projectId = detail.projectId + projectName = detail.projectName + remark = detail.remark + } +} + +/// 订单号解析器,负责从扫码内容或手动输入中提取可核销订单号。 +enum OrderNumberParser { + /// 从原始文本中解析订单号,兼容 URL、键值格式和纯订单号。 + static func parse(_ raw: String) -> String? { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + if let url = URL(string: text), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + let value = queryItems.first(where: { $0.name.lowercased() == "order_number" })?.value, + !value.isEmpty { + return value + } + + let patterns = [ + "order[_-]?number[=:]([A-Za-z0-9_-]+)", + "([A-Za-z0-9_-]{8,})" + ] + for pattern in patterns { + if let match = firstCapture(in: text, pattern: pattern) { + return match + } + } + + if !text.contains(" "), text.count >= 6 { + return text + } + return nil + } + + /// 返回第一个正则捕获组。 + private static func firstCapture(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + return String(text[captureRange]) + } +} + private extension KeyedDecodingContainer { /// 将任意常见 JSON 值宽松解码成字符串。 func decodeLossyString(forKey key: Key) throws -> String { @@ -258,4 +507,18 @@ private extension KeyedDecodingContainer { } return nil } + + /// 将 Double、Int 或数字字符串宽松解码成浮点数。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } } diff --git a/suixinkan/Features/Orders/Orders.md b/suixinkan/Features/Orders/Orders.md index d8fb643..f464b97 100644 --- a/suixinkan/Features/Orders/Orders.md +++ b/suixinkan/Features/Orders/Orders.md @@ -2,16 +2,19 @@ ## 模块职责 -Orders 模块负责登录后的订单 Tab。当前第一轮同步覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新和手动核销闭环。 +Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销和手动核销闭环。 -本轮不迁移扫码核销、退款、完整订单详情、历史拍摄、任务上传、视频预告等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。 +当前仍不迁移退款、历史拍摄、任务上传、视频预告、完整拍摄详情等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。 ## 核心对象 - `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。 - `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。 +- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。 - `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。 - `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。 +- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。 +- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。 ## 数据流程 @@ -19,8 +22,12 @@ Orders 模块负责登录后的订单 Tab。当前第一轮同步覆盖订单管 订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。 -核销订单支持手动输入订单号。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。 +订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。 + +核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。 ## 路由边界 -首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单 Tab 内点击详情、扫码、退款等未迁移能力时进入占位页,避免入口崩溃。 +首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单详情和核销订单详情已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。 + +退款、历史拍摄、任务上传、视频预告等尚未迁移的能力仍进入占位页,避免入口崩溃。 diff --git a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift new file mode 100644 index 0000000..2d2361d --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift @@ -0,0 +1,71 @@ +// +// OrderDetailViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。 +final class OrderDetailViewModel { + private(set) var detail: StoreOrderDetailResponse? + private(set) var loading = false + private(set) var errorMessage: String? + private(set) var contextMessage: String? + + /// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。 + var display: StoreOrderDetailDisplay { + if let detail { + return StoreOrderDetailDisplay(detail: detail) + } + return StoreOrderDetailDisplay(item: item) + } + + /// 详情中的项目配置。 + var projectInfo: StoreOrderProjectInfo? { + detail?.multiTravel?.projectInfo + } + + /// 详情中的拍摄点列表。 + var shootingList: [StoreOrderShootingListItem] { + detail?.multiTravel?.shootingList ?? [] + } + + private let item: OrderEntity + + /// 初始化详情 ViewModel,并注入列表订单作为兜底数据。 + init(item: OrderEntity) { + self.item = item + } + + /// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。 + func load(api: OrderServing, fallbackStoreId: Int?) async { + guard detail == nil, !loading else { return } + + guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else { + contextMessage = "缺少门店上下文,当前展示订单摘要。" + return + } + + loading = true + errorMessage = nil + contextMessage = nil + defer { loading = false } + + do { + detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: item.orderNumber) + } catch { + errorMessage = error.localizedDescription + contextMessage = "详情加载失败,当前展示订单摘要。" + } + } + + /// 清理当前错误提示。 + func clearError() { + errorMessage = nil + } +} diff --git a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift index e7ccb8a..21f941e 100644 --- a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift @@ -39,12 +39,12 @@ final class OrdersViewModel { } /// 根据当前子入口刷新对应列表。 - func reload(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws { + func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws { switch selectedEntry { case .storeOrders: try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading) case .verificationOrders: - try await reloadWriteOffOrders(api: api, scenicId: scenicId, showLoading: showLoading) + try await reloadWriteOffOrders(api: api, scenicId: scenicId, storeId: storeId, showLoading: showLoading) } } @@ -103,7 +103,7 @@ final class OrdersViewModel { } /// 刷新核销订单列表,缺少景区时清空旧数据。 - func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, showLoading: Bool = true) async throws { + func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws { guard let scenicId else { writeOffOrders = [] writeOffTotal = 0 @@ -115,7 +115,7 @@ final class OrdersViewModel { if showLoading { loading = true } defer { if showLoading { loading = false } } - let result = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize) + let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize) writeOffOrders = result.list writeOffTotal = result.total writeOffPage = 1 @@ -123,13 +123,13 @@ final class OrdersViewModel { } /// 加载核销订单下一页。 - func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?) async throws { + func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil) async throws { guard !loadingMore, writeOffHasMore, let scenicId else { return } loadingMore = true defer { loadingMore = false } let nextPage = writeOffPage + 1 - let result = try await api.writeOffList(scenicId: scenicId, page: nextPage, pageSize: pageSize) + let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: nextPage, pageSize: pageSize) writeOffOrders.append(contentsOf: result.list) writeOffTotal = result.total writeOffPage = nextPage @@ -137,7 +137,7 @@ final class OrdersViewModel { } /// 手动核销订单,并在成功后刷新核销列表。 - func verify(api: OrderServing, scenicId: Int, orderNumber: String) async throws { + func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws { guard !isVerifying else { return } isVerifying = true @@ -148,13 +148,31 @@ final class OrdersViewModel { } try await api.writeOff(orderNumber: orderNumber) - let refreshed = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize) + let refreshed = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize) writeOffOrders = refreshed.list writeOffTotal = refreshed.total writeOffPage = 1 writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty } + /// 解析扫码结果并返回当前核销列表中的匹配订单。 + func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? { + guard let orderNumber = OrderNumberParser.parse(rawCode) else { + return nil + } + let matched = writeOffOrders.first { + $0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame + } + return (matched?.orderNumber ?? orderNumber, matched) + } + + /// 根据订单号查找当前核销列表中的订单。 + func writeOffOrder(matching orderNumber: String) -> WriteOffOrderItem? { + writeOffOrders.first { + $0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame + } + } + /// 用于测试重复提交场景的内部状态设置。 func markVerifyingForTests(orderNumber: String) { isVerifying = true diff --git a/suixinkan/Features/Orders/Views/OrderCodeScannerView.swift b/suixinkan/Features/Orders/Views/OrderCodeScannerView.swift new file mode 100644 index 0000000..36391ef --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderCodeScannerView.swift @@ -0,0 +1,330 @@ +// +// OrderCodeScannerView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import AVFoundation +import SwiftUI + +/// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。 +enum OrderScannerError: LocalizedError { + case cameraUnavailable + case permissionDenied + case invalidMetadata + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + return "当前设备不可用相机,无法扫码。" + case .permissionDenied: + return "未开启相机权限,请在系统设置中允许后重试。" + case .invalidMetadata: + return "未识别到有效条码,请重试。" + } + } +} + +/// SwiftUI 扫码页包装,提供标题、关闭按钮和底部扫码提示。 +struct OrderScannerPage: View { + let onClose: () -> Void + let onSuccess: (String) -> Void + let onFailure: (Error) -> Void + + var body: some View { + NavigationStack { + ZStack(alignment: .bottom) { + OrderCodeScannerView { result in + switch result { + case .success(let raw): + onSuccess(raw) + case .failure(let error): + onFailure(error) + } + } + .ignoresSafeArea() + + Label("请将二维码/条码放入取景框", systemImage: "qrcode.viewfinder") + .font(.system(size: AppMetrics.FontSize.footnote, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, AppMetrics.Spacing.medium) + .padding(.vertical, AppMetrics.Spacing.xSmall) + .background(.black.opacity(0.55), in: Capsule()) + .padding(.bottom, AppMetrics.Spacing.xxLarge) + } + .navigationTitle("扫码核销") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { + onClose() + } + .accessibilityIdentifier("scanner.close") + } + } + } + } +} + +/// AVFoundation 扫码桥接视图,负责把相机识别结果回传给 SwiftUI。 +struct OrderCodeScannerView: UIViewControllerRepresentable { + let onScanResult: (Result) -> Void + + /// 创建底层扫码控制器。 + func makeUIViewController(context: Context) -> OrderScannerViewController { + let controller = OrderScannerViewController() + controller.onScanResult = onScanResult + return controller + } + + /// 扫码控制器没有 SwiftUI 驱动的可变配置。 + func updateUIViewController(_ uiViewController: OrderScannerViewController, context: Context) {} +} + +/// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。 +final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onScanResult: ((Result) -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var videoDevice: AVCaptureDevice? + private var hasFinishedScan = false + + private let scanFrameView: UIView = { + let view = UIView() + view.layer.borderColor = UIColor.systemBlue.cgColor + view.layer.borderWidth = 2 + view.layer.cornerRadius = 12 + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let guideLabel: UILabel = { + let label = UILabel() + label.text = "将二维码/条形码放入框内自动识别" + label.textColor = .white + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var torchButton: UIButton = { + let button = Self.makeScannerButton(title: "手电筒", backgroundColor: UIColor.black.withAlphaComponent(0.42)) + button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside) + return button + }() + + private lazy var retryButton: UIButton = { + let button = Self.makeScannerButton(title: "重新扫描", backgroundColor: UIColor.systemBlue.withAlphaComponent(0.85)) + button.addTarget(self, action: #selector(restartScan), for: .touchUpInside) + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupControls() + checkPermissionAndStart() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.layer.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + updateTorch(enabled: false) + stopSession() + } + + /// 检查相机权限,并在授权后启动扫码。 + private func checkPermissionAndStart() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + startSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + guard let self else { return } + if granted { + self.startSession() + } else { + self.onScanResult?(.failure(OrderScannerError.permissionDenied)) + } + } + } + case .denied, .restricted: + onScanResult?(.failure(OrderScannerError.permissionDenied)) + @unknown default: + onScanResult?(.failure(OrderScannerError.permissionDenied)) + } + } + + /// 启动扫码会话。 + private func startSession() { + hasFinishedScan = false + guard !session.isRunning else { return } + + do { + try configureSession() + } catch { + onScanResult?(.failure(error)) + return + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.session.startRunning() + } + } + + /// 停止扫码会话。 + private func stopSession() { + guard session.isRunning else { return } + session.stopRunning() + } + + /// 创建扫码页底部操作按钮。 + private static func makeScannerButton(title: String, backgroundColor: UIColor) -> UIButton { + var attributedTitle = AttributedString(title) + attributedTitle.font = .systemFont(ofSize: 13, weight: .semibold) + + var configuration = UIButton.Configuration.filled() + configuration.attributedTitle = attributedTitle + configuration.baseForegroundColor = .white + configuration.baseBackgroundColor = backgroundColor + configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) + configuration.cornerStyle = .capsule + + let button = UIButton(type: .system) + button.configuration = configuration + button.translatesAutoresizingMaskIntoConstraints = false + return button + } + + /// 配置相机输入、元数据输出和预览层。 + private func configureSession() throws { + if previewLayer != nil { + return + } + + guard let videoDevice = AVCaptureDevice.default(for: .video) else { + throw OrderScannerError.cameraUnavailable + } + self.videoDevice = videoDevice + + let videoInput = try AVCaptureDeviceInput(device: videoDevice) + guard session.canAddInput(videoInput) else { + throw OrderScannerError.cameraUnavailable + } + session.addInput(videoInput) + + let metadataOutput = AVCaptureMetadataOutput() + guard session.canAddOutput(metadataOutput) else { + throw OrderScannerError.invalidMetadata + } + session.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [ + .qr, + .ean8, + .ean13, + .pdf417, + .code39, + .code93, + .code128, + .dataMatrix, + .aztec, + .itf14, + .upce + ] + + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.layer.bounds + view.layer.insertSublayer(preview, at: 0) + previewLayer = preview + updateTorchButtonState() + } + + /// 处理相机识别到的二维码或条形码内容。 + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + guard !hasFinishedScan else { return } + + guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let code = metadataObject.stringValue, + !code.isEmpty else { + return + } + + hasFinishedScan = true + stopSession() + UINotificationFeedbackGenerator().notificationOccurred(.success) + onScanResult?(.success(code)) + } + + /// 布局扫码框、提示文本和操作按钮。 + private func setupControls() { + view.addSubview(scanFrameView) + view.addSubview(guideLabel) + view.addSubview(torchButton) + view.addSubview(retryButton) + + NSLayoutConstraint.activate([ + scanFrameView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + scanFrameView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40), + scanFrameView.widthAnchor.constraint(equalToConstant: 240), + scanFrameView.heightAnchor.constraint(equalToConstant: 240), + guideLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + guideLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + guideLabel.topAnchor.constraint(equalTo: scanFrameView.bottomAnchor, constant: 14), + torchButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + torchButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18), + retryButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), + retryButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18) + ]) + } + + /// 根据设备能力更新手电筒按钮状态。 + private func updateTorchButtonState() { + let hasTorch = videoDevice?.hasTorch == true + torchButton.isEnabled = hasTorch + torchButton.alpha = hasTorch ? 1 : 0.45 + } + + /// 切换手电筒开关。 + @objc + private func toggleTorch() { + let next = !(videoDevice?.isTorchActive ?? false) + updateTorch(enabled: next) + } + + /// 设置手电筒状态。 + private func updateTorch(enabled: Bool) { + guard let device = videoDevice, device.hasTorch else { return } + do { + try device.lockForConfiguration() + if enabled { + try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel) + } else { + device.torchMode = .off + } + device.unlockForConfiguration() + torchButton.setTitle(enabled ? "关闭手电" : "手电筒", for: .normal) + } catch { + torchButton.setTitle("手电筒不可用", for: .normal) + } + } + + /// 重置扫描状态并重新启动识别。 + @objc + private func restartScan() { + hasFinishedScan = false + updateTorch(enabled: false) + startSession() + } +} diff --git a/suixinkan/Features/Orders/Views/OrderDetailViews.swift b/suixinkan/Features/Orders/Views/OrderDetailViews.swift new file mode 100644 index 0000000..a9060a8 --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderDetailViews.swift @@ -0,0 +1,314 @@ +// +// OrderDetailViews.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。 +struct StoreOrderDetailView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(RouterPath.self) private var router + @Environment(ToastCenter.self) private var toastCenter + + let item: OrderEntity + @State private var viewModel: OrderDetailViewModel + + /// 使用列表订单初始化详情页,并创建详情 ViewModel。 + init(item: OrderEntity) { + self.item = item + _viewModel = State(initialValue: OrderDetailViewModel(item: item)) + } + + var body: some View { + List { + if viewModel.loading { + Section { + HStack { + Spacer() + ProgressView("加载详情中...") + Spacer() + } + } + } + + if let contextMessage = viewModel.contextMessage { + Section { + Label(contextMessage, systemImage: "info.circle") + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + } + } + + Section("订单信息") { + OrderDetailRow(title: "订单号", value: viewModel.display.orderNumber) + Button("复制订单号") { + UIPasteboard.general.string = viewModel.display.orderNumber + toastCenter.show("订单号已复制") + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + OrderDetailRow(title: "订单状态", value: viewModel.display.orderStatusName) + OrderDetailRow(title: "订单类型", value: viewModel.display.orderTypeLabel) + OrderDetailRow(title: "创建时间", value: viewModel.display.createdAt) + OrderDetailRow(title: "付款时间", value: displayPayTime) + OrderDetailRow(title: "完成时间", value: viewModel.display.completeTime) + } + + Section("支付信息") { + OrderDetailRow(title: "付款金额", value: "¥\(emptyToZero(viewModel.display.actualPayAmount))") + OrderDetailRow(title: "退款金额", value: "¥\(emptyToZero(viewModel.display.actualRefundAmount))") + OrderDetailRow(title: "付款方式", value: viewModel.display.payTypeName) + OrderDetailRow(title: "用户 UID", value: "\(viewModel.display.userId)") + } + + Section("客户与项目") { + OrderDetailRow(title: "手机号", value: viewModel.display.phone) + OrderDetailRow(title: "关联项目", value: viewModel.display.projectName) + OrderDetailRow(title: "项目 ID", value: "\(viewModel.display.projectId)") + } + + if let projectInfo = viewModel.projectInfo { + Section("项目配置") { + OrderDetailRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)") + OrderDetailRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)") + OrderDetailRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)") + OrderDetailRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)") + } + } + + if let photoTravel = item.photoTravel { + Section("旅拍信息") { + OrderDetailRow(title: "核销时间", value: photoTravel.checkInTime) + OrderDetailRow(title: "修图张数", value: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum) 张") + OrderDetailRow(title: "视频剪辑", value: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum) 条") + } + } + + if !viewModel.shootingList.isEmpty { + Section("拍摄点") { + ForEach(viewModel.shootingList) { shooting in + ShootingPointRow(item: shooting) + } + } + } + + if !viewModel.display.remark.isEmpty { + Section("备注") { + Text(viewModel.display.remark) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + } + } + + Section("后续功能") { + placeholderButton("历史拍摄", title: "历史拍摄") + placeholderButton("任务上传", title: "任务上传") + placeholderButton("视频预告", title: "视频预告") + placeholderButton("退款", title: "订单退款") + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("订单详情") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id) + } + .alert("提示", isPresented: errorBinding) { + Button("知道了", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var displayPayTime: String { + let payTime = viewModel.display.payTime + if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") { + return item.depositPayTime + } + return payTime + } + + private var errorBinding: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { visible in + if !visible { viewModel.clearError() } + } + ) + } + + /// 构建尚未迁移业务入口的占位按钮。 + private func placeholderButton(_ label: String, title: String) -> some View { + Button { + router.navigate(to: .placeholder(title: title)) + } label: { + HStack { + Text(label) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.textSecondary) + } + } + .foregroundStyle(AppDesign.textPrimary) + } + + /// 将空金额转换为 0,避免详情页出现孤立货币符号。 + private func emptyToZero(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value + } +} + +/// 核销订单详情页,展示核销列表项信息并支持再次发起核销。 +struct WriteOffOrderDetailView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(ToastCenter.self) private var toastCenter + + let item: WriteOffOrderItem + @State private var verifying = false + @State private var showVerifyConfirmation = false + + var body: some View { + List { + Section("核销订单") { + OrderDetailRow(title: "订单号", value: item.orderNumber) + Button("复制订单号") { + UIPasteboard.general.string = item.orderNumber + toastCenter.show("订单号已复制") + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + OrderDetailRow(title: "项目名称", value: item.projectName) + OrderDetailRow(title: "核销状态", value: displayVerificationStatus) + OrderDetailRow(title: "订单状态", value: item.orderStatusName) + OrderDetailRow(title: "订单金额", value: "¥\(item.orderAmount)") + OrderDetailRow(title: "手机号", value: item.userPhone) + OrderDetailRow(title: "支付时间", value: item.payTime) + OrderDetailRow(title: "核销时间", value: item.orderVerificationTime) + } + + Section { + Button(verifying ? "核销中..." : "确认核销") { + showVerifyConfirmation = true + } + .frame(maxWidth: .infinity, alignment: .center) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + .listRowBackground(canVerify ? AppDesign.primary : Color(hex: 0x9CA3AF)) + .disabled(!canVerify || verifying) + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("核销订单详情") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) { + Button("确认核销") { + Task { await verify() } + } + Button("取消", role: .cancel) {} + } message: { + Text(""" + 订单号:\(item.orderNumber) + 项目:\(item.projectName.isEmpty ? "--" : item.projectName) + 金额:¥\(item.orderAmount) + """) + } + } + + private var displayVerificationStatus: String { + let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "待核销" : text + } + + private var canVerify: Bool { + !displayVerificationStatus.contains("已核销") && + !displayVerificationStatus.contains("已退款") && + !displayVerificationStatus.contains("已完成") + } + + /// 调用核销接口并提示结果。 + private func verify() async { + guard !verifying else { return } + guard accountContext.currentScenic?.id != nil else { + toastCenter.show("缺少景区上下文") + return + } + + verifying = true + defer { verifying = false } + + do { + try await ordersAPI.writeOff(orderNumber: item.orderNumber) + toastCenter.show("核销成功") + } catch { + toastCenter.show(error.localizedDescription) + } + } +} + +/// 订单详情键值行,统一二级页面的字段排版。 +private struct OrderDetailRow: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.small) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .frame(width: 76, alignment: .leading) + Spacer(minLength: AppMetrics.Spacing.small) + Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + .multilineTextAlignment(.trailing) + } + } +} + +/// 拍摄点展示行,表示单个打卡点的拍摄人员、状态和评分。 +private struct ShootingPointRow: View { + let item: StoreOrderShootingListItem + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(statusText) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary) + } + OrderDetailRow(title: "摄影师", value: item.photogName) + OrderDetailRow(title: "评分", value: String(format: "%.1f", max(item.startAvg, item.start))) + } + .padding(.vertical, AppMetrics.Spacing.xxSmall) + } + + private var title: String { + let value = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName + return value.isEmpty ? "未命名打卡点" : value + } + + private var statusText: String { + switch item.status { + case 1: + return "待拍摄" + case 2: + return "已拍摄" + default: + return "未知" + } + } +} diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift index 52ceb75..1ca1b78 100644 --- a/suixinkan/Features/Orders/Views/OrdersView.swift +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -20,13 +20,21 @@ struct OrdersView: View { @State private var viewModel = OrdersViewModel() @State private var manualOrderNumber = "" @State private var showDateFilterSheet = false + @State private var showScanner = false @State private var showVerifyConfirmation = false + @State private var pendingVerifyOrder: WriteOffOrderItem? @State private var pendingVerifyOrderNumber: String? + @State private var matchedScannedOrderNumber: String? + @State private var scanHintMessage: String? private var currentScenicId: Int? { accountContext.currentScenic?.id } + private var currentStoreId: Int? { + accountContext.currentStore?.id + } + private var currentRoleId: Int? { permissionContext.currentRole?.id } @@ -47,28 +55,36 @@ struct OrdersView: View { } var body: some View { - ScrollView { - VStack(spacing: AppMetrics.Spacing.small) { - entrySection + ScrollViewReader { scrollProxy in + ScrollView { + VStack(spacing: AppMetrics.Spacing.small) { + entrySection - if currentScenicId == nil { - ContentUnavailableView( - "缺少经营上下文", - systemImage: "mountain.2", - description: Text("请先在首页选择景区后查看订单。") - ) - .frame(minHeight: 360) - } else if viewModel.selectedEntry == .storeOrders { - storeOrdersSection - } else { - writeOffSection + if currentScenicId == nil { + ContentUnavailableView( + "缺少经营上下文", + systemImage: "mountain.2", + description: Text("请先在首页选择景区后查看订单。") + ) + .frame(minHeight: 360) + } else if viewModel.selectedEntry == .storeOrders { + storeOrdersSection + } else { + writeOffSection + } + } + .frame(maxWidth: contentMaxWidth) + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.top, AppMetrics.Spacing.medium) + .padding(.bottom, AppMetrics.Spacing.pageVertical) + .frame(maxWidth: .infinity) + } + .onChange(of: matchedScannedOrderNumber) { _, orderNumber in + guard let orderNumber else { return } + withAnimation(.easeInOut(duration: 0.25)) { + scrollProxy.scrollTo(orderNumber, anchor: .center) } } - .frame(maxWidth: contentMaxWidth) - .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) - .padding(.top, AppMetrics.Spacing.medium) - .padding(.bottom, AppMetrics.Spacing.pageVertical) - .frame(maxWidth: .infinity) } .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationTitle("订单") @@ -92,10 +108,24 @@ struct OrdersView: View { Task { await verify(orderNumber: orderNumber) } } Button("取消", role: .cancel) { + pendingVerifyOrder = nil pendingVerifyOrderNumber = nil } } message: { - Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "") + Text(verifyDialogMessage) + } + .fullScreenCover(isPresented: $showScanner) { + OrderScannerPage( + onClose: { showScanner = false }, + onSuccess: { rawCode in + showScanner = false + handleScanResult(rawCode) + }, + onFailure: { error in + showScanner = false + toastCenter.show(error.localizedDescription) + } + ) } .sheet(isPresented: $showDateFilterSheet) { OrdersDateFilterSheet( @@ -220,7 +250,7 @@ struct OrdersView: View { private var writeOffActionSection: some View { VStack(spacing: AppMetrics.Spacing.small) { Button { - router.navigate(to: .placeholder(title: "扫码核销")) + showScanner = true } label: { Label("扫码核销", systemImage: "qrcode.viewfinder") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) @@ -232,6 +262,13 @@ struct OrdersView: View { .buttonStyle(.plain) .accessibilityIdentifier("orders.scan") + if let scanHintMessage { + Label(scanHintMessage, systemImage: "info.circle") + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(spacing: AppMetrics.Spacing.xSmall) { TextField("手动输入订单号", text: $manualOrderNumber) .textInputAutocapitalization(.never) @@ -377,7 +414,7 @@ struct OrdersView: View { .background(.white, in: RoundedRectangle(cornerRadius: 8)) .contentShape(Rectangle()) .onTapGesture { - router.navigate(to: .placeholder(title: "订单详情")) + router.navigate(to: .orders(.storeDetail(item))) } .accessibilityIdentifier("orders.store.card.\(item.orderNumber)") } @@ -406,7 +443,7 @@ struct OrdersView: View { .foregroundStyle(AppDesign.primary) Spacer() Button("详情") { - router.navigate(to: .placeholder(title: "核销订单详情")) + router.navigate(to: .orders(.writeOffDetail(item))) } .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(AppDesign.primary) @@ -417,6 +454,7 @@ struct OrdersView: View { .accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)") Button(verifyButtonTitle(for: item)) { + pendingVerifyOrder = item pendingVerifyOrderNumber = item.orderNumber showVerifyConfirmation = true } @@ -432,7 +470,12 @@ struct OrdersView: View { } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) - .background(.white, in: RoundedRectangle(cornerRadius: 8)) + .background(cardBackgroundColor(for: item), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : Color.clear, lineWidth: 2) + } + .id(item.orderNumber) } private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View { @@ -472,6 +515,7 @@ struct OrdersView: View { try await viewModel.reload( api: ordersAPI, scenicId: currentScenicId, + storeId: currentStoreId, roleId: currentRoleId, showLoading: showLoading ) @@ -490,7 +534,7 @@ struct OrdersView: View { private func loadMoreWriteOffOrders() async { do { - try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId) + try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId) } catch { toastCenter.show(error.localizedDescription) } @@ -502,27 +546,73 @@ struct OrdersView: View { toastCenter.show("请输入订单号") return } - pendingVerifyOrderNumber = orderNumber + if let matched = viewModel.writeOffOrder(matching: orderNumber) { + pendingVerifyOrder = matched + pendingVerifyOrderNumber = matched.orderNumber + matchedScannedOrderNumber = matched.orderNumber + scanHintMessage = "已定位到列表订单:\(matched.orderNumber)" + } else { + pendingVerifyOrder = nil + pendingVerifyOrderNumber = orderNumber + matchedScannedOrderNumber = nil + scanHintMessage = "未在当前列表定位到订单,将按输入订单号发起核销。" + } showVerifyConfirmation = true } private func verify(orderNumber: String) async { guard let scenicId = currentScenicId else { return } do { - try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber) + try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber) manualOrderNumber = "" + pendingVerifyOrder = nil pendingVerifyOrderNumber = nil + matchedScannedOrderNumber = nil + scanHintMessage = nil toastCenter.show("核销成功") } catch { toastCenter.show(error.localizedDescription) } } + /// 处理扫码返回的原始内容,并进入二次确认核销流程。 + private func handleScanResult(_ rawCode: String) { + guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else { + toastCenter.show("扫码成功,但未识别到有效订单号,请手动输入。") + return + } + + manualOrderNumber = result.orderNumber + pendingVerifyOrder = result.matched + pendingVerifyOrderNumber = result.orderNumber + + if let matched = result.matched { + matchedScannedOrderNumber = matched.orderNumber + scanHintMessage = "已定位到列表订单:\(matched.orderNumber)" + } else { + matchedScannedOrderNumber = nil + scanHintMessage = "未在当前列表定位到订单,已填入订单号,可继续核销。" + } + showVerifyConfirmation = true + } + private func canVerify(_ item: WriteOffOrderItem) -> Bool { let status = displayVerificationStatus(for: item) return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成") } + private var verifyDialogMessage: String { + let orderNumber = pendingVerifyOrderNumber ?? "-" + if let order = pendingVerifyOrder ?? viewModel.writeOffOrder(matching: orderNumber) { + return """ + 订单号:\(order.orderNumber) + 项目:\(order.projectName.isEmpty ? "--" : order.projectName) + 金额:¥\(order.orderAmount) + """ + } + return "订单号:\(orderNumber)" + } + private func displayVerificationStatus(for item: WriteOffOrderItem) -> String { let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? "待核销" : text @@ -536,6 +626,10 @@ struct OrdersView: View { canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E) } + private func cardBackgroundColor(for item: WriteOffOrderItem) -> Color { + matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white + } + private func statusColor(for status: Int) -> Color { switch status { case 18: diff --git a/suixinkanTests/Fixtures/store_order_detail_success.json b/suixinkanTests/Fixtures/store_order_detail_success.json new file mode 100644 index 0000000..a470726 --- /dev/null +++ b/suixinkanTests/Fixtures/store_order_detail_success.json @@ -0,0 +1,42 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "order_number": "ORDV2001", + "order_type": "19", + "order_type_label": "多点旅拍", + "created_at": "2026-05-23 04:39:00", + "order_status": "20", + "order_status_name": "已支付", + "actual_pay_amount": "299.50", + "actual_refund_amount": 0, + "pay_type_name": "微信支付", + "pay_time": "2026-05-23 04:40:00", + "complete_time": "", + "user_id": "7001", + "phone": "13800000000", + "project_id": "5101", + "project_name": "亲子跟拍", + "remark": "需要精修", + "multi_travel": { + "project_info": { + "settle_spot_num": "3", + "single_spot_material_num": 8, + "single_spot_photo_num": "2", + "single_spot_video_num": 1 + }, + "shooting_list": [ + { + "scenic_spot_id": "901", + "photog_uid": "42", + "scenic_spot_name": "东门", + "staff_name": "", + "photog_name": "张三", + "status": "2", + "start_avg": "4.8", + "start": 5 + } + ] + } + } +} diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index bacbc35..3b25e5e 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase { func testPushedRoutesHideTabBarByDefault() { XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed) } /// 测试切换订单 Tab 时会保存订单内部子入口。 @@ -25,4 +27,30 @@ final class NavigationRouterTests: XCTestCase { XCTAssertEqual(router.selectedTab, .orders) XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) } + + /// 测试订单模块真实路由可被压入导航栈。 + func testOrderRoutesCanBePushedIntoRouterPath() throws { + let router = RouterPath() + let order = try Self.firstOrder() + let writeOff = try Self.firstWriteOffOrder() + + router.navigate(to: .orders(.storeDetail(order))) + router.navigate(to: .orders(.writeOffDetail(writeOff))) + + XCTAssertEqual(router.path.count, 2) + XCTAssertEqual(router.path[0], .orders(.storeDetail(order))) + XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff))) + } + + /// 返回测试用订单管理列表项。 + private static func firstOrder() throws -> OrderEntity { + let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") + return try XCTUnwrap(payload.list.first) + } + + /// 返回测试用核销订单列表项。 + private static func firstWriteOffOrder() throws -> WriteOffOrderItem { + let payload = try TestFixture.payload(ListPayload.self, named: "writeoff_list_success") + return try XCTUnwrap(payload.list.first) + } } diff --git a/suixinkanTests/OrderDetailViewModelTests.swift b/suixinkanTests/OrderDetailViewModelTests.swift new file mode 100644 index 0000000..90ac028 --- /dev/null +++ b/suixinkanTests/OrderDetailViewModelTests.swift @@ -0,0 +1,120 @@ +// +// OrderDetailViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 订单详情 ViewModel 测试,覆盖详情加载、缺失上下文和错误兜底。 +final class OrderDetailViewModelTests: XCTestCase { + /// 测试有门店 ID 时请求详情接口并展示服务端字段。 + func testLoadRequestsDetailWhenStoreIdExists() async throws { + let item = try firstOrder() + let api = DetailMockOrderService() + api.detailResponses = [try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")] + let viewModel = OrderDetailViewModel(item: item) + + await viewModel.load(api: api, fallbackStoreId: 66) + + XCTAssertEqual(api.detailCalls.count, 1) + XCTAssertEqual(api.detailCalls[0].storeId, 66) + XCTAssertEqual(api.detailCalls[0].orderNumber, "ORDV2001") + XCTAssertEqual(viewModel.display.projectName, "亲子跟拍") + XCTAssertEqual(viewModel.projectInfo?.settleSpotNum, 3) + XCTAssertEqual(viewModel.shootingList.first?.scenicSpotName, "东门") + XCTAssertNil(viewModel.errorMessage) + } + + /// 测试缺少门店 ID 时不请求接口并保留列表摘要。 + func testLoadWithoutStoreIdUsesSummaryOnly() async throws { + let item = try firstOrder() + let api = DetailMockOrderService() + let viewModel = OrderDetailViewModel(item: item) + + await viewModel.load(api: api, fallbackStoreId: nil) + + XCTAssertTrue(api.detailCalls.isEmpty) + XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") + XCTAssertEqual(viewModel.contextMessage, "缺少门店上下文,当前展示订单摘要。") + } + + /// 测试详情接口失败时保留摘要并暴露错误信息。 + func testLoadFailureKeepsSummaryAndExposesError() async throws { + let item = try firstOrder() + let api = DetailMockOrderService() + api.detailError = APIError.httpStatus(500, "server error") + let viewModel = OrderDetailViewModel(item: item) + + await viewModel.load(api: api, fallbackStoreId: 66) + + XCTAssertEqual(api.detailCalls.count, 1) + XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") + XCTAssertEqual(viewModel.contextMessage, "详情加载失败,当前展示订单摘要。") + XCTAssertNotNil(viewModel.errorMessage) + } + + /// 测试详情模型兼容后端数字和字符串混合字段。 + func testDetailResponseDecodesMixedNumericAndStringFields() throws { + let detail = try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") + + XCTAssertEqual(detail.orderType, 19) + XCTAssertEqual(detail.actualRefundAmount, "0") + XCTAssertEqual(detail.userId, 7001) + XCTAssertEqual(detail.multiTravel?.projectInfo?.singleSpotVideoNum, 1) + XCTAssertEqual(detail.multiTravel?.shootingList.first?.startAvg, 4.8) + } + + /// 返回订单列表 fixture 中的第一条订单。 + private func firstOrder() throws -> OrderEntity { + let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") + return try XCTUnwrap(payload.list.first) + } +} + +@MainActor +/// 订单详情服务测试替身,专门用于详情 ViewModel 测试。 +private final class DetailMockOrderService: OrderServing { + var detailResponses: [StoreOrderDetailResponse] = [] + var detailError: Error? + private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] + + /// 订单列表接口在详情测试中不会被调用。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + XCTFail("orderList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 核销列表接口在详情测试中不会被调用。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + XCTFail("writeOffList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 核销接口在详情测试中不会被调用。 + func writeOff(orderNumber: String) async throws { + XCTFail("writeOff should not be called") + } + + /// 返回预置的订单详情响应。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + detailCalls.append((storeId: storeId, orderNumber: orderNumber)) + if let detailError { + throw detailError + } + return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() + } +} diff --git a/suixinkanTests/OrderNumberParserTests.swift b/suixinkanTests/OrderNumberParserTests.swift new file mode 100644 index 0000000..fd6d153 --- /dev/null +++ b/suixinkanTests/OrderNumberParserTests.swift @@ -0,0 +1,38 @@ +// +// OrderNumberParserTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +/// 订单号解析器测试,覆盖扫码内容中的常见订单号格式。 +final class OrderNumberParserTests: XCTestCase { + /// 测试从 URL query 中解析 order_number。 + func testParseOrderNumberFromURLQuery() { + XCTAssertEqual( + OrderNumberParser.parse("https://example.com/check?foo=1&order_number=ORD123456"), + "ORD123456" + ) + } + + /// 测试从 key-value 文本中解析订单号。 + func testParseOrderNumberFromKeyValueText() { + XCTAssertEqual(OrderNumberParser.parse("order_number=ORD123456"), "ORD123456") + XCTAssertEqual(OrderNumberParser.parse("order-number:ORD654321"), "ORD654321") + } + + /// 测试从纯文本中解析订单号。 + func testParsePlainOrderNumberText() { + XCTAssertEqual(OrderNumberParser.parse(" VERIFY001 "), "VERIFY001") + } + + /// 测试无效内容不会返回订单号。 + func testParseInvalidTextReturnsNil() { + XCTAssertNil(OrderNumberParser.parse("")) + XCTAssertNil(OrderNumberParser.parse("abc def")) + XCTAssertNil(OrderNumberParser.parse("12345")) + } +} diff --git a/suixinkanTests/OrdersViewModelTests.swift b/suixinkanTests/OrdersViewModelTests.swift index 834f5bf..0983e30 100644 --- a/suixinkanTests/OrdersViewModelTests.swift +++ b/suixinkanTests/OrdersViewModelTests.swift @@ -87,7 +87,7 @@ final class OrdersViewModelTests: XCTestCase { api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() - try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88, storeId: 66) XCTAssertFalse(viewModel.writeOffOrders.isEmpty) try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil) @@ -97,6 +97,7 @@ final class OrdersViewModelTests: XCTestCase { XCTAssertEqual(viewModel.writeOffPage, 1) XCTAssertFalse(viewModel.writeOffHasMore) XCTAssertEqual(api.writeOffListCalls.count, 1) + XCTAssertEqual(api.writeOffListCalls[0].storeId, 66) } /// 测试核销订单最后一页不会继续请求。 @@ -121,11 +122,12 @@ final class OrdersViewModelTests: XCTestCase { api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() - try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001") + try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001") XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) XCTAssertEqual(api.writeOffListCalls.count, 1) XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88) + XCTAssertEqual(api.writeOffListCalls[0].storeId, 66) XCTAssertEqual(api.writeOffListCalls[0].page, 1) XCTAssertEqual(viewModel.writeOffOrders.count, 1) XCTAssertEqual(viewModel.writeOffPage, 1) @@ -133,6 +135,32 @@ final class OrdersViewModelTests: XCTestCase { XCTAssertNil(viewModel.currentVerifyingOrderNumber) } + /// 测试扫码命中当前核销列表时返回可高亮订单。 + func testScanResultMatchesCurrentWriteOffOrder() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + + let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "https://example.com/scan?order_number=VERIFY001")) + + XCTAssertEqual(result.orderNumber, "VERIFY001") + XCTAssertEqual(result.matched?.orderNumber, "VERIFY001") + } + + /// 测试扫码未命中当前列表时仍保留解析出的订单号。 + func testScanResultKeepsParsedOrderNumberWhenCurrentListDoesNotContainIt() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + + let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order-number=VERIFY999")) + + XCTAssertEqual(result.orderNumber, "VERIFY999") + XCTAssertNil(result.matched) + } + /// 测试重复核销提交会被忽略。 func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws { let api = MockOrderService() @@ -220,15 +248,18 @@ private final class MockOrderService: OrderServing { struct WriteOffListCall: Equatable { let scenicId: Int + let storeId: Int? let page: Int let pageSize: Int } var storeResponses: [ListPayload] = [] var writeOffResponses: [ListPayload] = [] + var detailResponses: [StoreOrderDetailResponse] = [] var verifyError: Error? private(set) var orderListCalls: [OrderListCall] = [] private(set) var writeOffListCalls: [WriteOffListCall] = [] + private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] private(set) var verifiedOrderNumbers: [String] = [] /// 返回测试订单管理列表。 @@ -260,8 +291,8 @@ private final class MockOrderService: OrderServing { } /// 返回测试核销订单列表。 - func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { - writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, page: page, pageSize: pageSize)) + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize)) return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst() } @@ -272,6 +303,12 @@ private final class MockOrderService: OrderServing { throw verifyError } } + + /// 返回测试订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + detailCalls.append((storeId: storeId, orderNumber: orderNumber)) + return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() + } } /// URLSession 测试替身,记录请求并返回固定响应。