taler-android

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

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 }