taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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 }