Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ public extension PersistedProduct {
.filter(Columns.downloadable == false)
.order(Columns.name.collating(.localizedCaseInsensitiveCompare))
}

/// Searches for a POS-supported product by global unique ID
/// - Parameters:
/// - siteID: The site ID
/// - globalUniqueID: The global unique ID (barcode) to search for
/// - Returns: A query request that matches products with the given global unique ID
static func posProductByGlobalUniqueID(siteID: Int64, globalUniqueID: String) -> QueryInterfaceRequest<PersistedProduct> {
return PersistedProduct
.filter(Columns.siteID == siteID)
.filter(Columns.globalUniqueID == globalUniqueID)
}
}

// periphery:ignore - TODO: remove ignore when populating database
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,34 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord {
through: productVariationImage,
using: PersistedProductVariationImage.image,
key: "image")

// Relationship to parent product
public static let parentProduct = belongsTo(PersistedProduct.self,
using: ForeignKey([Columns.siteID, Columns.productID],
to: [PersistedProduct.Columns.siteID, PersistedProduct.Columns.id]))
}

// MARK: - Point of Sale Requests
public extension PersistedProductVariation {
/// Returns a request for non-downloadable variations of a parent product, ordered by ID
static func posVariationsRequest(siteID: Int64, parentProductID: Int64) -> QueryInterfaceRequest<PersistedProductVariation> {
return PersistedProductVariation
.filter(Columns.siteID == siteID && Columns.productID == parentProductID)
.filter(Columns.siteID == siteID)
.filter(Columns.productID == parentProductID)
.filter(Columns.downloadable == false)
.order(Columns.id)
}

/// Searches for a POS-supported variation by global unique ID
/// - Parameters:
/// - siteID: The site ID
/// - globalUniqueID: The global unique ID (barcode) to search for
/// - Returns: A query request that matches variations with the given global unique ID
static func posVariationByGlobalUniqueID(siteID: Int64, globalUniqueID: String) -> QueryInterfaceRequest<PersistedProductVariation> {
return PersistedProductVariation
.filter(Columns.siteID == siteID)
.filter(Columns.globalUniqueID == globalUniqueID)
}
}

// periphery:ignore - TODO: remove ignore when populating database
Expand Down
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a minor detail, but in the remote implementation, we throw PointOfSaleBarcodeScanError.noParentProductForVariation in case the parent product is not found

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 😊

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, it's not actually possible due to integrity constraints. If the parent is deleted, or not added in the first place, the variation would be deleted as well. I couldn't even make a test (involving the db) that the error is thrown... but I've added one that documents it shouldn't happen. b5f0a1f

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, got it! Thanks for checking.

}
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 {
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this needs to be removed, since we don't filter out downloadable from the query. We don't want to filter them out to show an appropriate error.

// 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as with downloadable.

// 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)
}
}
}
Loading