Skip to content

Commit b6a03c3

Browse files
authored
[Local Catalog] Update barcode scanning to use local catalog (#16263)
2 parents ae833e3 + b5f0a1f commit b6a03c3

File tree

7 files changed

+866
-8
lines changed

7 files changed

+866
-8
lines changed

Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ public extension PersistedProduct {
100100
.filter(Columns.downloadable == false)
101101
.order(Columns.name.collating(.localizedCaseInsensitiveCompare))
102102
}
103+
104+
/// Searches for a POS-supported product by global unique ID
105+
/// - Parameters:
106+
/// - siteID: The site ID
107+
/// - globalUniqueID: The global unique ID (barcode) to search for
108+
/// - Returns: A query request that matches products with the given global unique ID
109+
static func posProductByGlobalUniqueID(siteID: Int64, globalUniqueID: String) -> QueryInterfaceRequest<PersistedProduct> {
110+
return PersistedProduct
111+
.filter(Columns.siteID == siteID)
112+
.filter(Columns.globalUniqueID == globalUniqueID)
113+
}
103114
}
104115

105116
// periphery:ignore - TODO: remove ignore when populating database

Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,34 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord {
7878
through: productVariationImage,
7979
using: PersistedProductVariationImage.image,
8080
key: "image")
81+
82+
// Relationship to parent product
83+
public static let parentProduct = belongsTo(PersistedProduct.self,
84+
using: ForeignKey([Columns.siteID, Columns.productID],
85+
to: [PersistedProduct.Columns.siteID, PersistedProduct.Columns.id]))
8186
}
8287

8388
// MARK: - Point of Sale Requests
8489
public extension PersistedProductVariation {
8590
/// Returns a request for non-downloadable variations of a parent product, ordered by ID
8691
static func posVariationsRequest(siteID: Int64, parentProductID: Int64) -> QueryInterfaceRequest<PersistedProductVariation> {
8792
return PersistedProductVariation
88-
.filter(Columns.siteID == siteID && Columns.productID == parentProductID)
93+
.filter(Columns.siteID == siteID)
94+
.filter(Columns.productID == parentProductID)
8995
.filter(Columns.downloadable == false)
9096
.order(Columns.id)
9197
}
98+
99+
/// Searches for a POS-supported variation by global unique ID
100+
/// - Parameters:
101+
/// - siteID: The site ID
102+
/// - globalUniqueID: The global unique ID (barcode) to search for
103+
/// - Returns: A query request that matches variations with the given global unique ID
104+
static func posVariationByGlobalUniqueID(siteID: Int64, globalUniqueID: String) -> QueryInterfaceRequest<PersistedProductVariation> {
105+
return PersistedProductVariation
106+
.filter(Columns.siteID == siteID)
107+
.filter(Columns.globalUniqueID == globalUniqueID)
108+
}
92109
}
93110

94111
// periphery:ignore - TODO: remove ignore when populating database
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import Foundation
2+
import protocol Storage.GRDBManagerProtocol
3+
import class WooFoundation.CurrencySettings
4+
5+
/// Service for handling barcode scanning using local GRDB catalog
6+
public final class PointOfSaleLocalBarcodeScanService: PointOfSaleBarcodeScanServiceProtocol {
7+
private let grdbManager: GRDBManagerProtocol
8+
private let siteID: Int64
9+
private let itemMapper: PointOfSaleItemMapperProtocol
10+
11+
public init(siteID: Int64,
12+
grdbManager: GRDBManagerProtocol,
13+
currencySettings: CurrencySettings,
14+
itemMapper: PointOfSaleItemMapperProtocol? = nil) {
15+
self.siteID = siteID
16+
self.grdbManager = grdbManager
17+
self.itemMapper = itemMapper ?? PointOfSaleItemMapper(currencySettings: currencySettings)
18+
}
19+
20+
/// Looks up a POSItem using a barcode scan string from the local GRDB catalog
21+
/// - Parameter barcode: The barcode string from a scan (global unique identifier)
22+
/// - Returns: A POSItem if found, or throws an error
23+
public func getItem(barcode: String) async throws(PointOfSaleBarcodeScanError) -> POSItem {
24+
do {
25+
if let product = try searchProductByGlobalUniqueID(barcode) {
26+
return try convertProductToItem(product, scannedCode: barcode)
27+
}
28+
29+
if let variationAndParent = try searchVariationByGlobalUniqueID(barcode) {
30+
return try convertVariationToItem(variationAndParent.variation, parentProduct: variationAndParent.parentProduct, scannedCode: barcode)
31+
}
32+
33+
throw PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)
34+
} catch let error as PointOfSaleBarcodeScanError {
35+
throw error
36+
} catch {
37+
throw PointOfSaleBarcodeScanError.loadingError(scannedCode: barcode, underlyingError: error)
38+
}
39+
}
40+
41+
// MARK: - Product Search
42+
43+
private func searchProductByGlobalUniqueID(_ globalUniqueID: String) throws -> PersistedProduct? {
44+
try grdbManager.databaseConnection.read { db in
45+
try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db)
46+
}
47+
}
48+
49+
// MARK: - Variation Search
50+
51+
private func searchVariationByGlobalUniqueID(_ globalUniqueID: String) throws -> (variation: PersistedProductVariation, parentProduct: PersistedProduct)? {
52+
try grdbManager.databaseConnection.read { db in
53+
guard let variation = try PersistedProductVariation.posVariationByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) else {
54+
return nil
55+
}
56+
// Fetch parent product using the relationship
57+
guard let parentProduct = try variation.request(for: PersistedProductVariation.parentProduct).fetchOne(db) else {
58+
throw PointOfSaleBarcodeScanError.noParentProductForVariation(scannedCode: globalUniqueID)
59+
}
60+
return (variation, parentProduct)
61+
}
62+
}
63+
64+
// MARK: - Conversion to POSItem
65+
66+
private func convertProductToItem(_ persistedProduct: PersistedProduct, scannedCode: String) throws(PointOfSaleBarcodeScanError) -> POSItem {
67+
do {
68+
let posProduct = try persistedProduct.toPOSProduct(db: grdbManager.databaseConnection)
69+
70+
guard !posProduct.downloadable else {
71+
throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode, productName: posProduct.name)
72+
}
73+
74+
// Validate product type - only simple products can be scanned directly
75+
// Variable parent products cannot be added to cart (only their variations can)
76+
guard posProduct.productType == .simple else {
77+
throw PointOfSaleBarcodeScanError.unsupportedProductType(
78+
scannedCode: scannedCode,
79+
productName: posProduct.name,
80+
productType: posProduct.productType
81+
)
82+
}
83+
84+
// Convert to POSItem
85+
let items = itemMapper.mapProductsToPOSItems(products: [posProduct])
86+
guard let item = items.first else {
87+
throw PointOfSaleBarcodeScanError.unknown(scannedCode: scannedCode)
88+
}
89+
90+
return item
91+
} catch let error as PointOfSaleBarcodeScanError {
92+
throw error
93+
} catch {
94+
throw PointOfSaleBarcodeScanError.mappingError(scannedCode: scannedCode, underlyingError: error)
95+
}
96+
}
97+
98+
private func convertVariationToItem(_ persistedVariation: PersistedProductVariation,
99+
parentProduct: PersistedProduct,
100+
scannedCode: String) throws(PointOfSaleBarcodeScanError) -> POSItem {
101+
do {
102+
// Convert both variation and parent to POS models
103+
let posVariation = try persistedVariation.toPOSProductVariation(db: grdbManager.databaseConnection)
104+
let parentPOSProduct = try parentProduct.toPOSProduct(db: grdbManager.databaseConnection)
105+
106+
// Map to POSItem
107+
guard let mappedParent = itemMapper.mapProductsToPOSItems(products: [parentPOSProduct]).first,
108+
case .variableParentProduct(let variableParentProduct) = mappedParent,
109+
let item = itemMapper.mapVariationsToPOSItems(variations: [posVariation], parentProduct: variableParentProduct).first else {
110+
throw PointOfSaleBarcodeScanError.variationCouldNotBeConverted(scannedCode: scannedCode)
111+
}
112+
113+
guard !persistedVariation.downloadable else {
114+
throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode,
115+
productName: variationName(for: item))
116+
}
117+
118+
return item
119+
} catch let error as PointOfSaleBarcodeScanError {
120+
throw error
121+
} catch {
122+
throw PointOfSaleBarcodeScanError.mappingError(scannedCode: scannedCode, underlyingError: error)
123+
}
124+
}
125+
126+
private func variationName(for item: POSItem) -> String {
127+
guard case .variation(let posVariation) = item else {
128+
return Localization.unknownVariationName
129+
}
130+
return posVariation.name
131+
}
132+
}
133+
134+
private extension PointOfSaleLocalBarcodeScanService {
135+
enum Localization {
136+
static let unknownVariationName = NSLocalizedString(
137+
"pointOfSale.barcodeScanning.unresolved.variation.name",
138+
value: "Unknown",
139+
comment: "A placeholder name when we can't determine the name of a variation for an error message")
140+
}
141+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import Foundation
2+
import Testing
3+
@testable import Storage
4+
5+
@Suite("PersistedProduct Barcode Query Tests")
6+
struct PersistedProductBarcodeQueryTests {
7+
private let siteID: Int64 = 123
8+
private var grdbManager: GRDBManager!
9+
10+
init() async throws {
11+
grdbManager = try GRDBManager()
12+
13+
// Initialize site
14+
try await grdbManager.databaseConnection.write { db in
15+
try PersistedSite(id: siteID).insert(db)
16+
}
17+
}
18+
19+
// MARK: - Global Unique ID Query Tests
20+
21+
@Test("posProductByGlobalUniqueID finds product with matching global unique ID")
22+
func test_finds_product_by_global_unique_id() async throws {
23+
// Given
24+
let globalUniqueID = "UPC-123456"
25+
let product = PersistedProduct(
26+
id: 1,
27+
siteID: siteID,
28+
name: "Test Product",
29+
productTypeKey: "simple",
30+
fullDescription: nil,
31+
shortDescription: nil,
32+
sku: "SKU-001",
33+
globalUniqueID: globalUniqueID,
34+
price: "10.00",
35+
downloadable: false,
36+
parentID: 0,
37+
manageStock: false,
38+
stockQuantity: nil,
39+
stockStatusKey: "instock"
40+
)
41+
try await insertProduct(product)
42+
43+
// When
44+
let result = try await grdbManager.databaseConnection.read { db in
45+
try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db)
46+
}
47+
48+
// Then
49+
#expect(result != nil)
50+
#expect(result?.id == 1)
51+
#expect(result?.name == "Test Product")
52+
#expect(result?.globalUniqueID == globalUniqueID)
53+
}
54+
55+
@Test("posProductByGlobalUniqueID returns nil when no match")
56+
func test_returns_nil_when_no_global_unique_id_match() async throws {
57+
// When
58+
let result = try await grdbManager.databaseConnection.read { db in
59+
try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: "NONEXISTENT").fetchOne(db)
60+
}
61+
62+
// Then
63+
#expect(result == nil)
64+
}
65+
66+
// MARK: - Site Isolation Tests
67+
68+
@Test("Queries only return products from specified site")
69+
func test_queries_respect_site_isolation() async throws {
70+
// Given
71+
let otherSiteID: Int64 = 456
72+
let barcode = "SHARED-BARCODE"
73+
74+
// Insert site
75+
try await grdbManager.databaseConnection.write { db in
76+
try PersistedSite(id: otherSiteID).insert(db)
77+
}
78+
79+
// Insert product for our site
80+
let ourProduct = PersistedProduct(
81+
id: 7,
82+
siteID: siteID,
83+
name: "Our Product",
84+
productTypeKey: "simple",
85+
fullDescription: nil,
86+
shortDescription: nil,
87+
sku: barcode,
88+
globalUniqueID: barcode,
89+
price: "10.00",
90+
downloadable: false,
91+
parentID: 0,
92+
manageStock: false,
93+
stockQuantity: nil,
94+
stockStatusKey: "instock"
95+
)
96+
97+
// Insert product for other site
98+
let otherProduct = PersistedProduct(
99+
id: 8,
100+
siteID: otherSiteID,
101+
name: "Other Site Product",
102+
productTypeKey: "simple",
103+
fullDescription: nil,
104+
shortDescription: nil,
105+
sku: barcode,
106+
globalUniqueID: barcode,
107+
price: "20.00",
108+
downloadable: false,
109+
parentID: 0,
110+
manageStock: false,
111+
stockQuantity: nil,
112+
stockStatusKey: "instock"
113+
)
114+
115+
try await insertProduct(ourProduct)
116+
try await insertProduct(otherProduct)
117+
118+
// When
119+
let resultByGlobalID = try await grdbManager.databaseConnection.read { db in
120+
try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: barcode).fetchOne(db)
121+
}
122+
123+
// Then
124+
#expect(resultByGlobalID?.siteID == siteID)
125+
#expect(resultByGlobalID?.id == 7)
126+
}
127+
128+
// MARK: - Helper Methods
129+
130+
private func insertProduct(_ product: PersistedProduct) async throws {
131+
try await grdbManager.databaseConnection.write { db in
132+
try product.insert(db)
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)