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