taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

OrderManager.swift (17264B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Bohdan, Volodymyr Potuzhnyi
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 import Foundation
     18 import Combine
     19 
     20 private let allProductsCategoryId = -1
     21 private let uncategorizedCategoryId = -2
     22 private let legacyDefaultCategoryName = "Default"
     23 
     24 enum RestartState { case enabled, disabled, undo }
     25 
     26 @MainActor
     27 class LiveOrder: ObservableObject, Identifiable {
     28     let id: Int
     29     private let currency: String
     30     private let productsByCategory: [Category: [ConfigProduct]]
     31     private let canAddProductCheck: (ConfigProduct) -> Bool
     32     private let onChanged: () -> Void
     33 
     34     @Published var order: Order
     35     @Published var restartState: RestartState = .disabled
     36     @Published var selectedOrderLine: ConfigProduct?
     37     var lastAddedProduct: ConfigProduct?
     38     private var undoOrder: Order?
     39 
     40     var availableCategories: [Int: Category] {
     41         Dictionary(uniqueKeysWithValues: productsByCategory.keys.map { ($0.id, $0) })
     42     }
     43 
     44     var orderTotal: Amount { order.total }
     45 
     46     var modifyOrderAllowed: Bool {
     47         restartState != .disabled && selectedOrderLine != nil
     48     }
     49 
     50     var increaseOrderAllowed: Bool {
     51         guard let selected = selectedOrderLine else { return false }
     52         return canAddProductCheck(selected)
     53     }
     54 
     55     var selectedProductKey: String? {
     56         selectedOrderLine?.id
     57     }
     58 
     59     init(
     60         id: Int,
     61         currency: String,
     62         productsByCategory: [Category: [ConfigProduct]],
     63         canAddProduct: @escaping (ConfigProduct) -> Bool,
     64         onChanged: @escaping () -> Void
     65     ) {
     66         self.id = id
     67         self.currency = currency
     68         self.productsByCategory = productsByCategory
     69         self.canAddProductCheck = canAddProduct
     70         self.onChanged = onChanged
     71         self.order = Order(id: id, currency: currency, availableCategories: Dictionary(uniqueKeysWithValues: productsByCategory.keys.map { ($0.id, $0) }), products: [])
     72     }
     73 
     74     func addProduct(_ product: ConfigProduct) {
     75         guard canAddProductCheck(product) else { return }
     76         lastAddedProduct = product
     77         order = order.adding(product)
     78         restartState = .enabled
     79         repairSelection()
     80         onChanged()
     81     }
     82 
     83     func removeProduct(_ product: ConfigProduct) {
     84         let removedIndex = order.products.firstIndex(where: { $0.id == product.id })
     85         lastAddedProduct = nil
     86         order = order.removing(product)
     87         restartState = order.products.isEmpty ? .disabled : .enabled
     88         if let idx = order.products.firstIndex(where: { $0.id == product.id }) {
     89             selectedOrderLine = order.products[idx]
     90         } else {
     91             let products = order.products
     92             if products.isEmpty {
     93                 selectedOrderLine = nil
     94             } else if let ri = removedIndex {
     95                 let newIdx = min(ri, products.count - 1)
     96                 selectedOrderLine = products[newIdx]
     97             } else {
     98                 selectedOrderLine = products.last
     99             }
    100         }
    101         onChanged()
    102     }
    103 
    104     var isEmpty: Bool { order.products.isEmpty }
    105 
    106     func restartOrUndo() {
    107         if restartState == .undo {
    108             if let undo = undoOrder {
    109                 order = undo
    110                 restartState = .enabled
    111                 undoOrder = nil
    112             }
    113         } else {
    114             undoOrder = order
    115             order = Order(id: id, currency: currency, availableCategories: availableCategories, products: [])
    116             restartState = .undo
    117         }
    118         repairSelection()
    119         onChanged()
    120     }
    121 
    122     func selectOrderLine(_ product: ConfigProduct?) {
    123         lastAddedProduct = nil
    124         selectedOrderLine = product
    125         onChanged()
    126     }
    127 
    128     func repairSelection() {
    129         let products = order.products
    130         if products.isEmpty {
    131             selectedOrderLine = nil
    132             return
    133         }
    134         if let lastAdded = lastAddedProduct, products.contains(where: { $0.id == lastAdded.id }) {
    135             selectedOrderLine = products.first(where: { $0.id == lastAdded.id })
    136         } else if let current = selectedOrderLine, products.contains(where: { $0.id == current.id }) {
    137             selectedOrderLine = products.first(where: { $0.id == current.id })
    138         } else {
    139             selectedOrderLine = products.last
    140         }
    141     }
    142 
    143     func increaseSelectedOrderLine() {
    144         guard let orderLine = selectedOrderLine else { return }
    145         addProduct(orderLine)
    146     }
    147 
    148     func decreaseSelectedOrderLine() {
    149         guard let orderLine = selectedOrderLine else { return }
    150         removeProduct(orderLine)
    151     }
    152 }
    153 
    154 @MainActor
    155 class OrderManager: ObservableObject, ConfigurationReceiver {
    156     @Published var categories: [Category] = []
    157     @Published var products: [ConfigProduct] = []
    158     @Published var currentOrderId: Int = 0
    159 
    160     private var currency: String = ""
    161     private var currencySpec: CurrencySpecification?
    162     private var productsByCategory: [Category: [ConfigProduct]] = [:]
    163     private var productsById: [String: ConfigProduct] = [:]
    164     private var currentCategory: Category?
    165     private var orders: OrderedDictionary<Int, LiveOrder> = OrderedDictionary()
    166     private var orderCounter: Int = 0
    167 
    168     var currentLiveOrder: LiveOrder? {
    169         orders[currentOrderId]
    170     }
    171 
    172     func getOrder(_ orderId: Int) -> LiveOrder? {
    173         orders[orderId]
    174     }
    175 
    176     func onConfigurationReceived(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? {
    177         return applyConfiguration(posConfig: posConfig, currency: currency, currencySpec: currencySpec, resetOrders: true)
    178     }
    179 
    180     func onInventoryUpdated(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? {
    181         return applyConfiguration(posConfig: posConfig, currency: currency, currencySpec: currencySpec, resetOrders: false)
    182     }
    183 
    184     private func applyConfiguration(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?, resetOrders: Bool) -> String? {
    185         let existingProductsByStableKey = Dictionary(
    186             productsById.values.map { ($0.stableKey, $0) },
    187             uniquingKeysWith: { _, last in last }
    188         )
    189 
    190         guard !posConfig.categories.isEmpty else {
    191             return "No valid category found."
    192         }
    193 
    194         let selectedCategoryId = resetOrders ? allProductsCategoryId : currentCategory?.id
    195         let allProductsCategory = Category(id: allProductsCategoryId, name: S.productCategoryAll)
    196         let uncategorizedCategory = Category(id: uncategorizedCategoryId, name: S.productCategoryUncategorized)
    197         let visibleCategories = posConfig.categories.filter { !isLegacyDefaultCategory($0) }
    198         let legacyDefaultCategoryIds = Set(
    199             posConfig.categories.filter { isLegacyDefaultCategory($0) }.map { $0.id }
    200         )
    201 
    202         productsByCategory.removeAll()
    203         productsById.removeAll()
    204         productsByCategory[allProductsCategory] = []
    205         for cat in visibleCategories {
    206             productsByCategory[cat] = []
    207         }
    208         productsByCategory[uncategorizedCategory] = []
    209 
    210         for product in posConfig.products {
    211             let currencyMismatch = product.price.currency != currency
    212             let remainingStock = product.stockLimit
    213             let preservedId = existingProductsByStableKey[product.stableKey]?.id ?? product.id
    214             var p = ConfigProduct(
    215                 id: preservedId,
    216                 productId: product.productId,
    217                 productName: product.productName,
    218                 description: product.description,
    219                 descriptionI18n: product.descriptionI18n,
    220                 price: product.price,
    221                 pricesAreNet: product.pricesAreNet,
    222                 location: product.location,
    223                 image: product.image,
    224                 taxes: product.taxes,
    225                 categories: product.categories,
    226                 quantity: product.quantity,
    227                 totalStock: product.totalStock,
    228                 unitTotalStock: product.unitTotalStock,
    229                 totalSold: product.totalSold,
    230                 unitTotalSold: product.unitTotalSold,
    231                 totalLost: product.totalLost,
    232                 unitTotalLost: product.unitTotalLost,
    233                 availableToSell: !currencyMismatch && (remainingStock == nil || remainingStock! > 0),
    234                 remainingStock: remainingStock,
    235                 currencyMismatch: currencyMismatch
    236             )
    237 
    238             productsById[p.id] = p
    239             productsByCategory[allProductsCategory]?.append(p)
    240 
    241             if p.categories.isEmpty {
    242                 productsByCategory[uncategorizedCategory]?.append(p)
    243             }
    244             for catId in p.categories {
    245                 if legacyDefaultCategoryIds.contains(catId) {
    246                     productsByCategory[uncategorizedCategory]?.append(p)
    247                     continue
    248                 }
    249                 if let cat = visibleCategories.first(where: { $0.id == catId }) {
    250                     productsByCategory[cat]?.append(p)
    251                 } else {
    252                     productsByCategory[uncategorizedCategory]?.append(p)
    253                 }
    254             }
    255         }
    256 
    257         self.currency = currency
    258         self.currencySpec = currencySpec
    259 
    260         var categoryList = [allProductsCategory] + visibleCategories
    261         if let uncatProducts = productsByCategory[uncategorizedCategory], !uncatProducts.isEmpty {
    262             categoryList.append(uncategorizedCategory)
    263         } else {
    264             productsByCategory.removeValue(forKey: uncategorizedCategory)
    265         }
    266 
    267         let selectedCategory = categoryList.first(where: { $0.id == selectedCategoryId }) ?? allProductsCategory
    268         for i in categoryList.indices {
    269             categoryList[i].selected = (categoryList[i].id == selectedCategory.id)
    270         }
    271         currentCategory = selectedCategory
    272         categories = categoryList
    273         products = getVisibleProducts()
    274 
    275         if resetOrders {
    276             orders.removeAll()
    277             orderCounter = 0
    278             orders[0] = createOrder(0)
    279             currentOrderId = 0
    280         } else {
    281             trimOrdersToStockLimits()
    282         }
    283         return nil
    284     }
    285 
    286     func selectCategory(_ category: Category) {
    287         currentCategory = category
    288         categories = categories.map { cat in
    289             var c = cat
    290             c.selected = (cat.id == category.id)
    291             return c
    292         }
    293         updateVisibleProducts()
    294     }
    295 
    296     func addProduct(orderId: Int, product: ConfigProduct) {
    297         orders[orderId]?.addProduct(product)
    298     }
    299 
    300     func nextOrder() {
    301         let keys = orders.orderedKeys
    302         guard let idx = keys.firstIndex(of: currentOrderId) else { return }
    303 
    304         var nextId: Int?
    305         if idx + 1 < keys.count {
    306             nextId = keys[idx + 1]
    307         }
    308         if nextId == nil {
    309             orderCounter += 1
    310             nextId = orderCounter
    311             orders[nextId!] = createOrder(nextId!)
    312         }
    313 
    314         let currentOrder = orders[currentOrderId]
    315         if currentOrder?.isEmpty == true {
    316             orders.removeValue(forKey: currentOrderId)
    317         } else {
    318             currentOrder?.lastAddedProduct = nil
    319         }
    320         currentOrderId = nextId!
    321         updateVisibleProducts()
    322     }
    323 
    324     func previousOrder() {
    325         let keys = orders.orderedKeys
    326         guard let idx = keys.firstIndex(of: currentOrderId), idx > 0 else { return }
    327         let previousId = keys[idx - 1]
    328         let currentOrder = orders[currentOrderId]
    329         if currentOrder?.isEmpty == true {
    330             orders.removeValue(forKey: currentOrderId)
    331         } else {
    332             currentOrder?.lastAddedProduct = nil
    333         }
    334         currentOrderId = previousId
    335         updateVisibleProducts()
    336     }
    337 
    338     func hasPreviousOrder() -> Bool {
    339         guard let keys = orders.orderedKeys.first else { return false }
    340         return currentOrderId != keys
    341     }
    342 
    343     func hasNextOrder() -> Bool {
    344         guard let liveOrder = orders[currentOrderId] else { return false }
    345         return liveOrder.restartState == .enabled || currentOrderId != orders.orderedKeys.last
    346     }
    347 
    348     func deleteCurrentOrder() {
    349         let keys = orders.orderedKeys
    350         guard let idx = keys.firstIndex(of: currentOrderId) else { return }
    351         orders.removeValue(forKey: currentOrderId)
    352         let replacementId: Int
    353         if orders.isEmpty {
    354             orders[currentOrderId] = createOrder(currentOrderId)
    355             replacementId = currentOrderId
    356         } else if idx < keys.count - 1 {
    357             replacementId = keys[idx + 1]
    358         } else {
    359             replacementId = keys[max(0, idx - 1)]
    360         }
    361         currentOrderId = replacementId
    362         updateVisibleProducts()
    363     }
    364 
    365     func onOrderPaid(_ orderId: Int) {
    366         if currentOrderId == orderId {
    367             if hasPreviousOrder() {
    368                 previousOrder()
    369             } else {
    370                 nextOrder()
    371             }
    372         }
    373         orders.removeValue(forKey: orderId)
    374         updateVisibleProducts()
    375     }
    376 
    377     func canAddProduct(_ product: ConfigProduct) -> Bool {
    378         guard let stock = remainingStock(product) else { return true }
    379         return stock > 0
    380     }
    381 
    382     private func remainingStock(_ product: ConfigProduct) -> Int? {
    383         guard let stockLimit = productsById[product.id]?.stockLimit ?? product.stockLimit else { return nil }
    384         let reserved = orders.values.reduce(0) { total, liveOrder in
    385             total + (liveOrder.order.products.first(where: { $0.id == product.id })?.quantity ?? 0)
    386         }
    387         return max(stockLimit - reserved, 0)
    388     }
    389 
    390     private func isLegacyDefaultCategory(_ category: Category) -> Bool {
    391         category.name.caseInsensitiveCompare(legacyDefaultCategoryName) == .orderedSame
    392     }
    393 
    394     private func createOrder(_ orderId: Int) -> LiveOrder {
    395         LiveOrder(
    396             id: orderId,
    397             currency: currency,
    398             productsByCategory: productsByCategory,
    399             canAddProduct: { [weak self] product in
    400                 self?.canAddProduct(product) ?? true
    401             },
    402             onChanged: { [weak self] in
    403                 self?.updateVisibleProducts()
    404             }
    405         )
    406     }
    407 
    408     private func getVisibleProducts() -> [ConfigProduct] {
    409         guard let cat = currentCategory else { return [] }
    410         return (productsByCategory[cat] ?? []).map(decorateProduct)
    411     }
    412 
    413     private func updateVisibleProducts() {
    414         products = getVisibleProducts()
    415         objectWillChange.send()
    416     }
    417 
    418     private func decorateProduct(_ product: ConfigProduct) -> ConfigProduct {
    419         let remaining = remainingStock(product)
    420         var p = product
    421         p.availableToSell = !product.currencyMismatch && (remaining == nil || remaining! > 0)
    422         p.remainingStock = remaining
    423         return p
    424     }
    425 
    426     private func trimOrdersToStockLimits() {
    427         for liveOrder in orders.values {
    428             var modified = false
    429             let trimmedProducts: [ConfigProduct] = liveOrder.order.products.compactMap { orderProduct in
    430                 guard let stockLimit = productsById[orderProduct.id]?.stockLimit else { return orderProduct }
    431                 if orderProduct.quantity <= stockLimit { return orderProduct }
    432                 modified = true
    433                 if stockLimit <= 0 { return nil }
    434                 var p = orderProduct
    435                 p.quantity = stockLimit
    436                 return p
    437             }
    438             if modified {
    439                 liveOrder.order = Order(
    440                     id: liveOrder.order.id,
    441                     currency: liveOrder.order.currency,
    442                     availableCategories: liveOrder.order.availableCategories,
    443                     products: trimmedProducts
    444                 )
    445                 liveOrder.repairSelection()
    446             }
    447         }
    448     }
    449 }
    450 
    451 struct OrderedDictionary<Key: Hashable, Value> {
    452     private var dict: [Key: Value] = [:]
    453     private(set) var orderedKeys: [Key] = []
    454 
    455     var isEmpty: Bool { dict.isEmpty }
    456     var count: Int { dict.count }
    457     var values: [Value] { orderedKeys.compactMap { dict[$0] } }
    458 
    459     subscript(key: Key) -> Value? {
    460         get { dict[key] }
    461         set {
    462             if let newValue {
    463                 if dict[key] == nil {
    464                     orderedKeys.append(key)
    465                 }
    466                 dict[key] = newValue
    467             } else {
    468                 dict.removeValue(forKey: key)
    469                 orderedKeys.removeAll { $0 == key }
    470             }
    471         }
    472     }
    473 
    474     mutating func removeValue(forKey key: Key) {
    475         dict.removeValue(forKey: key)
    476         orderedKeys.removeAll { $0 == key }
    477     }
    478 
    479     mutating func removeAll() {
    480         dict.removeAll()
    481         orderedKeys.removeAll()
    482     }
    483 }