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 }