Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 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 {
throw PointOfSaleBarcodeScanError.noParentProductForVariation(scannedCode: globalUniqueID)
}
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) 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,135 @@
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)
}

// 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