taler-android

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

OrderManager.kt (12794B)


      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             if (productCurrency != currency) {
    110                 Log.e(TAG, "Product $product has currency $productCurrency, $currency expected")
    111                 return context.getString(
    112                     R.string.config_error_currency, product.description, productCurrency, currency
    113                 )
    114             }
    115             val remainingStock = product.stockLimit
    116             val productWithSpec = product.copy(
    117                 id = existingProductsByStableKey[product.stableKey]?.id ?: product.id,
    118                 price = product.price.withSpec(currencySpec),
    119                 availableToSell = remainingStock == null || remainingStock > 0,
    120                 remainingStock = remainingStock,
    121             )
    122             productsById[productWithSpec.id] = productWithSpec
    123             productsByCategory.getValue(allProductsCategory).add(productWithSpec)
    124             if (product.categories.isEmpty()) {
    125                 productsByCategory.getValue(uncategorizedCategory).add(productWithSpec)
    126             }
    127             product.categories.forEach { categoryId ->
    128                 if (categoryId in legacyDefaultCategoryIds) {
    129                     productsByCategory.getValue(uncategorizedCategory).add(productWithSpec)
    130                     return@forEach
    131                 }
    132                 val category = visibleCategories.find { it.id == categoryId }
    133                 if (category == null) {
    134                     Log.e(TAG, "Product $product has unknown category $categoryId")
    135                     productsByCategory.getValue(uncategorizedCategory).add(productWithSpec)
    136                 } else {
    137                     productsByCategory.getValue(category).add(productWithSpec)
    138                 }
    139             }
    140         }
    141 
    142         this.currency = currency
    143         this.currencySpec = currencySpec
    144         val categoryList = buildList {
    145             add(allProductsCategory)
    146             addAll(visibleCategories)
    147             if (productsByCategory.getValue(uncategorizedCategory).isNotEmpty()) {
    148                 add(uncategorizedCategory)
    149             } else {
    150                 productsByCategory.remove(uncategorizedCategory)
    151             }
    152         }
    153         val selectedCategory =
    154             categoryList.firstOrNull { it.id == selectedCategoryId } ?: allProductsCategory
    155         categoryList.forEach { it.selected = it.id == selectedCategory.id }
    156         currentCategory = selectedCategory
    157         mCategories.postValue(categoryList)
    158         mProducts.postValue(getVisibleProducts())
    159 
    160         if (resetOrders) {
    161             orders.clear()
    162             orderCounter = 0
    163             orders[0] = createOrder(0)
    164             mCurrentOrderId.postValue(0)
    165         }
    166         return null
    167     }
    168 
    169     @UiThread
    170     internal fun getOrder(orderId: Int): LiveOrder {
    171         return orders[orderId] ?: throw IllegalArgumentException("Order not found: $orderId")
    172     }
    173 
    174     @UiThread
    175     internal fun nextOrder() {
    176         val currentId = currentOrderId.value!!
    177         var foundCurrentOrder = false
    178         var nextId: Int? = null
    179         for (orderId in orders.keys) {
    180             if (foundCurrentOrder) {
    181                 nextId = orderId
    182                 break
    183             }
    184             if (orderId == currentId) foundCurrentOrder = true
    185         }
    186         if (nextId == null) {
    187             nextId = ++orderCounter
    188             orders[nextId] = createOrder(nextId)
    189         }
    190         val currentOrder = order(currentId)
    191         if (currentOrder.isEmpty()) orders.remove(currentId)
    192         else currentOrder.lastAddedProduct = null
    193         mCurrentOrderId.value = requireNotNull(nextId)
    194         updateVisibleProducts()
    195     }
    196 
    197     @UiThread
    198     internal fun previousOrder() {
    199         val currentId = currentOrderId.value!!
    200         var previousId: Int? = null
    201         var foundCurrentOrder = false
    202         for (orderId in orders.keys) {
    203             if (orderId == currentId) {
    204                 foundCurrentOrder = true
    205                 break
    206             }
    207             previousId = orderId
    208         }
    209         if (previousId == null || !foundCurrentOrder) {
    210             throw AssertionError("Could not find previous order for $currentId")
    211         }
    212         val currentOrder = order(currentId)
    213         if (currentOrder.isEmpty()) orders.remove(currentId)
    214         else currentOrder.lastAddedProduct = null
    215         mCurrentOrderId.value = requireNotNull(previousId)
    216         updateVisibleProducts()
    217     }
    218 
    219     fun hasPreviousOrder(currentOrderId: Int): Boolean {
    220         return currentOrderId != orders.keys.first()
    221     }
    222 
    223     fun hasNextOrder(currentOrderId: Int) = order(currentOrderId).restartState.map { state ->
    224         state == ENABLED || currentOrderId != orders.keys.last()
    225     }
    226 
    227     internal fun setCurrentCategory(category: Category) {
    228         currentCategory = category
    229         val currentCategories = categories.value.orEmpty()
    230         val newCategories = currentCategories.map { existing ->
    231             existing.copy().also { copied ->
    232                 copied.selected = existing.id == category.id
    233             }
    234         }
    235         currentCategory = newCategories.firstOrNull { it.id == category.id } ?: category
    236         mCategories.postValue(newCategories)
    237         updateVisibleProducts()
    238     }
    239 
    240     @UiThread
    241     internal fun addProduct(orderId: Int, product: ConfigProduct) {
    242         order(orderId).addProduct(product)
    243     }
    244 
    245     @UiThread
    246     internal fun debugSeedCurrentOrder(productIds: List<String>) {
    247         val orderId = currentOrderId.value ?: return
    248         val liveOrder = order(orderId)
    249         productIds.forEach { productId ->
    250             productsById[productId]?.let(liveOrder::addProduct)
    251         }
    252         updateVisibleProducts()
    253     }
    254 
    255     @UiThread
    256     internal fun onOrderPaid(orderId: Int) {
    257         if (currentOrderId.value == orderId) {
    258             if (hasPreviousOrder(orderId)) previousOrder()
    259             else nextOrder()
    260         }
    261         orders.remove(orderId)
    262         updateVisibleProducts()
    263     }
    264 
    265     @UiThread
    266     internal fun deleteCurrentOrder() {
    267         val currentId = currentOrderId.value ?: return
    268         val orderIds = orders.keys.toList()
    269         val currentIndex = orderIds.indexOf(currentId)
    270         if (currentIndex == -1) return
    271 
    272         orders.remove(currentId)
    273         val replacementId = when {
    274             orders.isEmpty() -> {
    275                 orders[currentId] = createOrder(currentId)
    276                 currentId
    277             }
    278             currentIndex < orderIds.lastIndex -> orderIds[currentIndex + 1]
    279             else -> orderIds[currentIndex - 1]
    280         }
    281         mCurrentOrderId.value = replacementId
    282         updateVisibleProducts()
    283     }
    284 
    285     private fun order(orderId: Int): MutableLiveOrder {
    286         return orders[orderId] ?: throw IllegalStateException()
    287     }
    288 
    289     private fun isLegacyDefaultCategory(category: Category): Boolean {
    290         return category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true)
    291     }
    292 
    293     private fun createOrder(orderId: Int): MutableLiveOrder {
    294         return MutableLiveOrder(
    295             orderId,
    296             currency,
    297             currencySpec,
    298             productsByCategory,
    299             ::canAddProduct,
    300             ::updateVisibleProducts,
    301         )
    302     }
    303 
    304     private fun getVisibleProducts(): List<ConfigProduct> {
    305         val category = currentCategory ?: return emptyList()
    306         return productsByCategory[category].orEmpty().map(::decorateProduct)
    307     }
    308 
    309     private fun updateVisibleProducts() {
    310         mProducts.postValue(getVisibleProducts())
    311     }
    312 
    313     private fun decorateProduct(product: ConfigProduct): ConfigProduct {
    314         val remainingStock = remainingStock(product)
    315         return product.copy(
    316             availableToSell = remainingStock == null || remainingStock > 0,
    317             remainingStock = remainingStock,
    318         )
    319     }
    320 
    321     private fun canAddProduct(product: ConfigProduct): Boolean {
    322         return remainingStock(product)?.let { it > 0 } ?: true
    323     }
    324 
    325     private fun remainingStock(product: ConfigProduct): Int? {
    326         val stockLimit = productsById[product.id]?.stockLimit ?: product.stockLimit ?: return null
    327         val reserved = orders.values.sumOf { liveOrder ->
    328             liveOrder.order.value
    329                 ?.products
    330                 ?.find { it.id == product.id }
    331                 ?.quantity
    332                 ?: 0
    333         }
    334         return (stockLimit - reserved).coerceAtLeast(0)
    335     }
    336 }