-
Notifications
You must be signed in to change notification settings - Fork 121
[Local Catalog] Update barcode scanning to use local catalog #16263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
231b310
90c6c25
f0fda16
f09ecf4
9a84ddb
bf505b3
7fe5983
8455db8
cfa925c
8377ed0
42fa5b2
61539f9
8f80c01
c66708d
8136676
f01164d
b5f0a1f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import Foundation | ||
| import protocol Storage.GRDBManagerProtocol | ||
| import class WooFoundation.CurrencySettings | ||
|
|
||
| /// Service for handling barcode scanning using local GRDB catalog | ||
| public final class PointOfSaleLocalBarcodeScanService: PointOfSaleBarcodeScanServiceProtocol { | ||
| private let grdbManager: GRDBManagerProtocol | ||
| private let siteID: Int64 | ||
| private let itemMapper: PointOfSaleItemMapperProtocol | ||
|
|
||
| public init(siteID: Int64, | ||
| grdbManager: GRDBManagerProtocol, | ||
| currencySettings: CurrencySettings, | ||
| itemMapper: PointOfSaleItemMapperProtocol? = nil) { | ||
| self.siteID = siteID | ||
| self.grdbManager = grdbManager | ||
| self.itemMapper = itemMapper ?? PointOfSaleItemMapper(currencySettings: currencySettings) | ||
| } | ||
|
|
||
| /// Looks up a POSItem using a barcode scan string from the local GRDB catalog | ||
| /// - Parameter barcode: The barcode string from a scan (global unique identifier) | ||
| /// - Returns: A POSItem if found, or throws an error | ||
| public func getItem(barcode: String) async throws(PointOfSaleBarcodeScanError) -> POSItem { | ||
| do { | ||
| if let product = try searchProductByGlobalUniqueID(barcode) { | ||
| return try convertProductToItem(product, scannedCode: barcode) | ||
| } | ||
|
|
||
| if let variationAndParent = try searchVariationByGlobalUniqueID(barcode) { | ||
| return try await convertVariationToItem(variationAndParent.variation, parentProduct: variationAndParent.parentProduct, scannedCode: barcode) | ||
| } | ||
|
|
||
| throw PointOfSaleBarcodeScanError.notFound(scannedCode: barcode) | ||
| } catch let error as PointOfSaleBarcodeScanError { | ||
| throw error | ||
| } catch { | ||
| throw PointOfSaleBarcodeScanError.loadingError(scannedCode: barcode, underlyingError: error) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Product Search | ||
|
|
||
| private func searchProductByGlobalUniqueID(_ globalUniqueID: String) throws -> PersistedProduct? { | ||
| try grdbManager.databaseConnection.read { db in | ||
| try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Variation Search | ||
|
|
||
| private func searchVariationByGlobalUniqueID(_ globalUniqueID: String) throws -> (variation: PersistedProductVariation, parentProduct: PersistedProduct)? { | ||
| try grdbManager.databaseConnection.read { db in | ||
| guard let variation = try PersistedProductVariation.posVariationByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) else { | ||
| return nil | ||
| } | ||
| // Fetch parent product using the relationship | ||
| guard let parentProduct = try variation.request(for: PersistedProductVariation.parentProduct).fetchOne(db) else { | ||
| return nil | ||
|
||
| } | ||
| return (variation, parentProduct) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Conversion to POSItem | ||
|
|
||
| private func convertProductToItem(_ persistedProduct: PersistedProduct, scannedCode: String) throws(PointOfSaleBarcodeScanError) -> POSItem { | ||
| do { | ||
| let posProduct = try persistedProduct.toPOSProduct(db: grdbManager.databaseConnection) | ||
|
|
||
| guard !posProduct.downloadable else { | ||
| throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode, productName: posProduct.name) | ||
| } | ||
|
|
||
| // Validate product type - only simple products can be scanned directly | ||
| // Variable parent products cannot be added to cart (only their variations can) | ||
| guard posProduct.productType == .simple else { | ||
| throw PointOfSaleBarcodeScanError.unsupportedProductType( | ||
| scannedCode: scannedCode, | ||
| productName: posProduct.name, | ||
| productType: posProduct.productType | ||
| ) | ||
| } | ||
|
|
||
| // Convert to POSItem | ||
| let items = itemMapper.mapProductsToPOSItems(products: [posProduct]) | ||
| guard let item = items.first else { | ||
| throw PointOfSaleBarcodeScanError.unknown(scannedCode: scannedCode) | ||
| } | ||
|
|
||
| return item | ||
| } catch let error as PointOfSaleBarcodeScanError { | ||
| throw error | ||
| } catch { | ||
| throw PointOfSaleBarcodeScanError.mappingError(scannedCode: scannedCode, underlyingError: error) | ||
| } | ||
| } | ||
|
|
||
| private func convertVariationToItem(_ persistedVariation: PersistedProductVariation, | ||
| parentProduct: PersistedProduct, | ||
| scannedCode: String) async throws(PointOfSaleBarcodeScanError) -> POSItem { | ||
joshheald marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| do { | ||
| // Convert both variation and parent to POS models | ||
| let posVariation = try persistedVariation.toPOSProductVariation(db: grdbManager.databaseConnection) | ||
| let parentPOSProduct = try parentProduct.toPOSProduct(db: grdbManager.databaseConnection) | ||
|
|
||
| // Map to POSItem | ||
| guard let mappedParent = itemMapper.mapProductsToPOSItems(products: [parentPOSProduct]).first, | ||
| case .variableParentProduct(let variableParentProduct) = mappedParent, | ||
| let item = itemMapper.mapVariationsToPOSItems(variations: [posVariation], parentProduct: variableParentProduct).first else { | ||
| throw PointOfSaleBarcodeScanError.variationCouldNotBeConverted(scannedCode: scannedCode) | ||
| } | ||
|
|
||
| guard !persistedVariation.downloadable else { | ||
| throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode, | ||
| productName: variationName(for: item)) | ||
| } | ||
|
|
||
| return item | ||
| } catch let error as PointOfSaleBarcodeScanError { | ||
| throw error | ||
| } catch { | ||
| throw PointOfSaleBarcodeScanError.mappingError(scannedCode: scannedCode, underlyingError: error) | ||
| } | ||
| } | ||
|
|
||
| private func variationName(for item: POSItem) -> String { | ||
| guard case .variation(let posVariation) = item else { | ||
| return Localization.unknownVariationName | ||
| } | ||
| return posVariation.name | ||
| } | ||
| } | ||
|
|
||
| private extension PointOfSaleLocalBarcodeScanService { | ||
| enum Localization { | ||
| static let unknownVariationName = NSLocalizedString( | ||
| "pointOfSale.barcodeScanning.unresolved.variation.name", | ||
| value: "Unknown", | ||
| comment: "A placeholder name when we can't determine the name of a variation for an error message") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import Storage | ||
|
|
||
| @Suite("PersistedProduct Barcode Query Tests") | ||
| struct PersistedProductBarcodeQueryTests { | ||
| private let siteID: Int64 = 123 | ||
| private var grdbManager: GRDBManager! | ||
|
|
||
| init() async throws { | ||
| grdbManager = try GRDBManager() | ||
|
|
||
| // Initialize site | ||
| try await grdbManager.databaseConnection.write { db in | ||
| try PersistedSite(id: siteID).insert(db) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Global Unique ID Query Tests | ||
|
|
||
| @Test("posProductByGlobalUniqueID finds product with matching global unique ID") | ||
| func test_finds_product_by_global_unique_id() async throws { | ||
| // Given | ||
| let globalUniqueID = "UPC-123456" | ||
| let product = PersistedProduct( | ||
| id: 1, | ||
| siteID: siteID, | ||
| name: "Test Product", | ||
| productTypeKey: "simple", | ||
| fullDescription: nil, | ||
| shortDescription: nil, | ||
| sku: "SKU-001", | ||
| globalUniqueID: globalUniqueID, | ||
| price: "10.00", | ||
| downloadable: false, | ||
| parentID: 0, | ||
| manageStock: false, | ||
| stockQuantity: nil, | ||
| stockStatusKey: "instock" | ||
| ) | ||
| try await insertProduct(product) | ||
|
|
||
| // When | ||
| let result = try await grdbManager.databaseConnection.read { db in | ||
| try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) | ||
| } | ||
|
|
||
| // Then | ||
| #expect(result != nil) | ||
| #expect(result?.id == 1) | ||
| #expect(result?.name == "Test Product") | ||
| #expect(result?.globalUniqueID == globalUniqueID) | ||
| } | ||
|
|
||
| @Test("posProductByGlobalUniqueID returns nil when no match") | ||
| func test_returns_nil_when_no_global_unique_id_match() async throws { | ||
| // When | ||
| let result = try await grdbManager.databaseConnection.read { db in | ||
| try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: "NONEXISTENT").fetchOne(db) | ||
| } | ||
|
|
||
| // Then | ||
| #expect(result == nil) | ||
| } | ||
|
|
||
| @Test("posProductByGlobalUniqueID filters out downloadable products") | ||
| func test_global_unique_id_query_filters_downloadable() async throws { | ||
|
||
| // Given | ||
| let globalUniqueID = "UPC-DOWNLOADABLE" | ||
| let downloadableProduct = PersistedProduct( | ||
| id: 2, | ||
| siteID: siteID, | ||
| name: "Downloadable Product", | ||
| productTypeKey: "simple", | ||
| fullDescription: nil, | ||
| shortDescription: nil, | ||
| sku: nil, | ||
| globalUniqueID: globalUniqueID, | ||
| price: "5.00", | ||
| downloadable: true, | ||
| parentID: 0, | ||
| manageStock: false, | ||
| stockQuantity: nil, | ||
| stockStatusKey: "instock" | ||
| ) | ||
| try await insertProduct(downloadableProduct) | ||
|
|
||
| // When | ||
| let result = try await grdbManager.databaseConnection.read { db in | ||
| try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) | ||
| } | ||
|
|
||
| // Then | ||
| #expect(result == nil) | ||
| } | ||
|
|
||
| @Test("posProductByGlobalUniqueID filters out unsupported product types") | ||
| func test_global_unique_id_query_filters_unsupported_types() async throws { | ||
|
||
| // Given | ||
| let globalUniqueID = "UPC-GROUPED" | ||
| let groupedProduct = PersistedProduct( | ||
| id: 3, | ||
| siteID: siteID, | ||
| name: "Grouped Product", | ||
| productTypeKey: "grouped", | ||
| fullDescription: nil, | ||
| shortDescription: nil, | ||
| sku: nil, | ||
| globalUniqueID: globalUniqueID, | ||
| price: "0.00", | ||
| downloadable: false, | ||
| parentID: 0, | ||
| manageStock: false, | ||
| stockQuantity: nil, | ||
| stockStatusKey: "instock" | ||
| ) | ||
| try await insertProduct(groupedProduct) | ||
|
|
||
| // When | ||
| let result = try await grdbManager.databaseConnection.read { db in | ||
| try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) | ||
| } | ||
|
|
||
| // Then | ||
| #expect(result == nil) | ||
| } | ||
|
|
||
| // MARK: - Site Isolation Tests | ||
|
|
||
| @Test("Queries only return products from specified site") | ||
| func test_queries_respect_site_isolation() async throws { | ||
| // Given | ||
| let otherSiteID: Int64 = 456 | ||
| let barcode = "SHARED-BARCODE" | ||
|
|
||
| // Insert site | ||
| try await grdbManager.databaseConnection.write { db in | ||
| try PersistedSite(id: otherSiteID).insert(db) | ||
| } | ||
|
|
||
| // Insert product for our site | ||
| let ourProduct = PersistedProduct( | ||
| id: 7, | ||
| siteID: siteID, | ||
| name: "Our Product", | ||
| productTypeKey: "simple", | ||
| fullDescription: nil, | ||
| shortDescription: nil, | ||
| sku: barcode, | ||
| globalUniqueID: barcode, | ||
| price: "10.00", | ||
| downloadable: false, | ||
| parentID: 0, | ||
| manageStock: false, | ||
| stockQuantity: nil, | ||
| stockStatusKey: "instock" | ||
| ) | ||
|
|
||
| // Insert product for other site | ||
| let otherProduct = PersistedProduct( | ||
| id: 8, | ||
| siteID: otherSiteID, | ||
| name: "Other Site Product", | ||
| productTypeKey: "simple", | ||
| fullDescription: nil, | ||
| shortDescription: nil, | ||
| sku: barcode, | ||
| globalUniqueID: barcode, | ||
| price: "20.00", | ||
| downloadable: false, | ||
| parentID: 0, | ||
| manageStock: false, | ||
| stockQuantity: nil, | ||
| stockStatusKey: "instock" | ||
| ) | ||
|
|
||
| try await insertProduct(ourProduct) | ||
| try await insertProduct(otherProduct) | ||
|
|
||
| // When | ||
| let resultByGlobalID = try await grdbManager.databaseConnection.read { db in | ||
| try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: barcode).fetchOne(db) | ||
| } | ||
|
|
||
| // Then | ||
| #expect(resultByGlobalID?.siteID == siteID) | ||
| #expect(resultByGlobalID?.id == 7) | ||
| } | ||
|
|
||
| // MARK: - Helper Methods | ||
|
|
||
| private func insertProduct(_ product: PersistedProduct) async throws { | ||
| try await grdbManager.databaseConnection.write { db in | ||
| try product.insert(db) | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.