OrderManager.kt (14303B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2020 Taler Systems S.A. 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 package net.taler.merchantpos.order 18 19 import android.content.Context 20 import android.util.Log 21 22 import androidx.annotation.UiThread 23 import androidx.lifecycle.LiveData 24 import androidx.lifecycle.MutableLiveData 25 import androidx.lifecycle.map 26 27 import net.taler.common.CurrencySpecification 28 import net.taler.merchantpos.R 29 import net.taler.merchantpos.config.Category 30 import net.taler.merchantpos.config.ConfigProduct 31 import net.taler.merchantpos.config.ConfigurationReceiver 32 import net.taler.merchantpos.config.PosConfig 33 import net.taler.merchantpos.order.RestartState.ENABLED 34 35 class OrderManager( 36 private val context: Context, 37 ) : ConfigurationReceiver 38 { 39 companion object 40 { 41 val TAG: String = OrderManager::class.java.simpleName 42 private const val ALL_PRODUCTS_CATEGORY_ID = -1 43 private const val UNCATEGORIZED_CATEGORY_ID = -2 44 private const val LEGACY_DEFAULT_CATEGORY_NAME = "Default" 45 } 46 47 private lateinit var currency: String 48 private var currencySpec: CurrencySpecification? = null 49 private var orderCounter: Int = 0 50 private var currentCategory: Category? = null 51 private val productsByCategory = HashMap<Category, ArrayList<ConfigProduct>>() 52 private val productsById = HashMap<String, ConfigProduct>() 53 private val orders = LinkedHashMap<Int, MutableLiveOrder>() 54 private val mCurrentOrderId = MutableLiveData<Int>() 55 private val mProducts = MutableLiveData<List<ConfigProduct>>() 56 private val mCategories = MutableLiveData<List<Category>>() 57 58 internal val currentOrderId: LiveData<Int> = mCurrentOrderId 59 internal val products: LiveData<List<ConfigProduct>> = mProducts 60 internal val categories: LiveData<List<Category>> = mCategories 61 62 override suspend fun onConfigurationReceived( 63 posConfig: PosConfig, 64 currency: String, 65 currencySpec: CurrencySpecification?, 66 ): String? = applyConfiguration(posConfig, currency, currencySpec, resetOrders = true) 67 68 override suspend fun onInventoryUpdated( 69 posConfig: PosConfig, 70 currency: String, 71 currencySpec: CurrencySpecification?, 72 ): String? = applyConfiguration(posConfig, currency, currencySpec, resetOrders = false) 73 74 private fun applyConfiguration( 75 posConfig: PosConfig, 76 currency: String, 77 currencySpec: CurrencySpecification?, 78 resetOrders: Boolean, 79 ): String? 80 { 81 val existingProductsByStableKey = productsById.values.associateBy { it.stableKey } 82 val selectedCategoryId = if (resetOrders) ALL_PRODUCTS_CATEGORY_ID else currentCategory?.id 83 val allProductsCategory = 84 Category( 85 ALL_PRODUCTS_CATEGORY_ID, 86 context.getString(R.string.product_category_all_objects), 87 ) 88 val uncategorizedCategory = 89 Category( 90 UNCATEGORIZED_CATEGORY_ID, 91 context.getString(R.string.product_category_uncategorized), 92 ) 93 val visibleCategories = posConfig.categories.filterNot(::isLegacyDefaultCategory) 94 val legacyDefaultCategoryIds = 95 posConfig.categories 96 .filter(::isLegacyDefaultCategory) 97 .map { it.id } 98 .toSet() 99 100 productsByCategory.clear() 101 productsById.clear() 102 productsByCategory[allProductsCategory] = ArrayList() 103 visibleCategories.forEach { category -> 104 category.selected = false 105 productsByCategory[category] = ArrayList() 106 } 107 productsByCategory[uncategorizedCategory] = ArrayList() 108 109 posConfig.products.forEach { product -> 110 val productCurrency = product.price.currency 111 val currencyMismatch = productCurrency != currency 112 if (currencyMismatch) 113 { 114 Log.w(TAG, "Product $product has currency $productCurrency, $currency expected") 115 } 116 val remainingStock = product.stockLimit 117 val productWithSpec = 118 product.copy( 119 id = existingProductsByStableKey[product.stableKey]?.id ?: product.id, 120 price = product.price.withSpec(currencySpec), 121 availableToSell = !currencyMismatch && (remainingStock == null || remainingStock > 0), 122 remainingStock = remainingStock, 123 currencyMismatch = currencyMismatch, 124 ) 125 productsById[productWithSpec.id] = productWithSpec 126 productsByCategory.getValue(allProductsCategory).add(productWithSpec) 127 if (product.categories.isEmpty()) 128 { 129 productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) 130 } 131 product.categories.forEach { categoryId -> 132 if (categoryId in legacyDefaultCategoryIds) 133 { 134 productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) 135 return@forEach 136 } 137 val category = visibleCategories.find { it.id == categoryId } 138 if (category == null) 139 { 140 Log.e(TAG, "Product $product has unknown category $categoryId") 141 productsByCategory.getValue(uncategorizedCategory).add(productWithSpec) 142 } 143 else 144 { 145 productsByCategory.getValue(category).add(productWithSpec) 146 } 147 } 148 } 149 150 this.currency = currency 151 this.currencySpec = currencySpec 152 val categoryList = 153 buildList { 154 add(allProductsCategory) 155 addAll(visibleCategories) 156 if (productsByCategory.getValue(uncategorizedCategory).isNotEmpty()) 157 { 158 add(uncategorizedCategory) 159 } 160 else 161 { 162 productsByCategory.remove(uncategorizedCategory) 163 } 164 } 165 val selectedCategory = 166 categoryList.firstOrNull { it.id == selectedCategoryId } ?: allProductsCategory 167 categoryList.forEach { it.selected = it.id == selectedCategory.id } 168 currentCategory = selectedCategory 169 mCategories.postValue(categoryList) 170 mProducts.postValue(getVisibleProducts()) 171 172 if (resetOrders) 173 { 174 orders.clear() 175 orderCounter = 0 176 orders[0] = createOrder(0) 177 mCurrentOrderId.postValue(0) 178 } 179 else 180 { 181 trimOrdersToStockLimits() 182 } 183 return null 184 } 185 186 @UiThread 187 internal fun getOrder(orderId: Int): LiveOrder = 188 orders[orderId] ?: throw IllegalArgumentException("Order not found: $orderId") 189 190 @UiThread 191 internal fun nextOrder() 192 { 193 val currentId = currentOrderId.value!! 194 var foundCurrentOrder = false 195 var nextId: Int? = null 196 for (orderId in orders.keys) 197 { 198 if (foundCurrentOrder) 199 { 200 nextId = orderId 201 break 202 } 203 if (orderId == currentId) foundCurrentOrder = true 204 } 205 if (nextId == null) 206 { 207 nextId = ++orderCounter 208 orders[nextId] = createOrder(nextId) 209 } 210 val currentOrder = order(currentId) 211 if (currentOrder.isEmpty()) 212 { 213 orders.remove(currentId) 214 } 215 mCurrentOrderId.value = requireNotNull(nextId) 216 updateVisibleProducts() 217 } 218 219 @UiThread 220 internal fun previousOrder() 221 { 222 val currentId = currentOrderId.value!! 223 var previousId: Int? = null 224 var foundCurrentOrder = false 225 for (orderId in orders.keys) 226 { 227 if (orderId == currentId) 228 { 229 foundCurrentOrder = true 230 break 231 } 232 previousId = orderId 233 } 234 if (previousId == null || 235 !foundCurrentOrder) 236 { 237 throw AssertionError("Could not find previous order for $currentId") 238 } 239 val currentOrder = order(currentId) 240 if (currentOrder.isEmpty()) 241 { 242 orders.remove(currentId) 243 } 244 mCurrentOrderId.value = requireNotNull(previousId) 245 updateVisibleProducts() 246 } 247 248 fun hasPreviousOrder(currentOrderId: Int): Boolean = currentOrderId != orders.keys.first() 249 250 fun hasNextOrder(currentOrderId: Int) = 251 order(currentOrderId).restartState.map { state -> 252 state == ENABLED || currentOrderId != orders.keys.last() 253 } 254 255 internal fun setCurrentCategory(category: Category) 256 { 257 currentCategory = category 258 val currentCategories = categories.value.orEmpty() 259 val newCategories = 260 currentCategories.map { existing -> 261 existing.copy().also { copied -> 262 copied.selected = existing.id == category.id 263 } 264 } 265 currentCategory = newCategories.firstOrNull { it.id == category.id } ?: category 266 mCategories.postValue(newCategories) 267 updateVisibleProducts() 268 } 269 270 @UiThread 271 internal fun addProduct( 272 orderId: Int, 273 product: ConfigProduct, 274 ) 275 { 276 order(orderId).addProduct(product) 277 } 278 279 @UiThread 280 internal fun debugSeedCurrentOrder(productIds: List<String>) 281 { 282 val orderId = currentOrderId.value ?: return 283 val liveOrder = order(orderId) 284 productIds.forEach { productId -> 285 productsById[productId]?.let(liveOrder::addProduct) 286 } 287 updateVisibleProducts() 288 } 289 290 @UiThread 291 internal fun onOrderPaid(orderId: Int) 292 { 293 if (currentOrderId.value == orderId) 294 { 295 if (hasPreviousOrder(orderId)) 296 { 297 previousOrder() 298 } 299 else 300 { 301 nextOrder() 302 } 303 } 304 orders.remove(orderId) 305 updateVisibleProducts() 306 } 307 308 @UiThread 309 internal fun deleteCurrentOrder() 310 { 311 val currentId = currentOrderId.value ?: return 312 val orderIds = orders.keys.toList() 313 val currentIndex = orderIds.indexOf(currentId) 314 if (currentIndex == -1) return 315 316 orders.remove(currentId) 317 val replacementId = 318 when 319 { 320 orders.isEmpty() -> 321 { 322 orders[currentId] = createOrder(currentId) 323 currentId 324 } 325 326 currentIndex < orderIds.lastIndex -> 327 { 328 orderIds[currentIndex + 1] 329 } 330 331 else -> 332 { 333 orderIds[currentIndex - 1] 334 } 335 } 336 mCurrentOrderId.value = replacementId 337 updateVisibleProducts() 338 } 339 340 private fun order(orderId: Int): MutableLiveOrder = orders[orderId] ?: throw IllegalStateException() 341 342 private fun isLegacyDefaultCategory(category: Category): Boolean = 343 category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true) 344 345 private fun createOrder(orderId: Int): MutableLiveOrder = 346 MutableLiveOrder( 347 orderId, 348 currency, 349 currencySpec, 350 productsByCategory, 351 ::canAddProduct, 352 ::updateVisibleProducts, 353 ) 354 355 private fun getVisibleProducts(): List<ConfigProduct> 356 { 357 val category = currentCategory ?: return emptyList() 358 return productsByCategory[category].orEmpty().map(::decorateProduct) 359 } 360 361 private fun updateVisibleProducts() 362 { 363 mProducts.postValue(getVisibleProducts()) 364 } 365 366 private fun decorateProduct(product: ConfigProduct): ConfigProduct 367 { 368 val remainingStock = remainingStock(product) 369 return product.copy( 370 availableToSell = !product.currencyMismatch && (remainingStock == null || remainingStock > 0), 371 remainingStock = remainingStock, 372 ) 373 } 374 375 private fun canAddProduct(product: ConfigProduct): Boolean = remainingStock(product)?.let { it > 0 } ?: true 376 377 private fun remainingStock(product: ConfigProduct): Int? 378 { 379 val stockLimit = productsById[product.id]?.stockLimit ?: product.stockLimit ?: return null 380 val reserved = 381 orders.values.sumOf { liveOrder -> 382 liveOrder.order.value 383 ?.products 384 ?.find { it.id == product.id } 385 ?.quantity 386 ?: 0 387 } 388 return (stockLimit - reserved).coerceAtLeast(0) 389 } 390 391 private fun trimOrdersToStockLimits() 392 { 393 for (liveOrder in orders.values) 394 { 395 val order = liveOrder.order.value ?: continue 396 var modified = false 397 val trimmedProducts = 398 order.products.mapNotNull { orderProduct -> 399 val stockLimit = productsById[orderProduct.id]?.stockLimit ?: return@mapNotNull orderProduct 400 if (orderProduct.quantity <= stockLimit) return@mapNotNull orderProduct 401 modified = true 402 if (stockLimit <= 0) 403 { 404 null 405 } 406 else 407 { 408 orderProduct.copy(quantity = stockLimit) 409 } 410 } 411 if (modified) 412 { 413 liveOrder.order.postValue(order.copy(products = trimmedProducts)) 414 } 415 } 416 } 417 }