OrderManager.kt
/*
* This file is part of GNU Taler
* (C) 2020 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos.order
import android.content.Context
import android.util.Log
import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import net.taler.common.CurrencySpecification
import net.taler.merchantpos.R
import net.taler.merchantpos.config.Category
import net.taler.merchantpos.config.ConfigProduct
import net.taler.merchantpos.config.ConfigurationReceiver
import net.taler.merchantpos.config.PosConfig
import net.taler.merchantpos.order.RestartState.ENABLED
class OrderManager(
private val context: Context,
) : ConfigurationReceiver
{
companion object
{
val TAG: String = OrderManager::class.java.simpleName
private const val ALL_PRODUCTS_CATEGORY_ID = -1
private const val UNCATEGORIZED_CATEGORY_ID = -2
private const val LEGACY_DEFAULT_CATEGORY_NAME = "Default"
}
private lateinit var currency: String
private var currencySpec: CurrencySpecification? = null
private var orderCounter: Int = 0
private var currentCategory: Category? = null
private val productsByCategory = HashMap<Category, ArrayList<ConfigProduct>>()
private val productsById = HashMap<String, ConfigProduct>()
private val orders = LinkedHashMap<Int, MutableLiveOrder>()
private val mCurrentOrderId = MutableLiveData<Int>()
private val mProducts = MutableLiveData<List<ConfigProduct>>()
private val mCategories = MutableLiveData<List<Category>>()
internal val currentOrderId: LiveData<Int> = mCurrentOrderId
internal val products: LiveData<List<ConfigProduct>> = mProducts
internal val categories: LiveData<List<Category>> = mCategories
override suspend fun onConfigurationReceived(
posConfig: PosConfig,
currency: String,
currencySpec: CurrencySpecification?,
): String? = applyConfiguration(posConfig, currency, currencySpec, resetOrders = true)
override suspend fun onInventoryUpdated(
posConfig: PosConfig,
currency: String,
currencySpec: CurrencySpecification?,
): String? = applyConfiguration(posConfig, currency, currencySpec, resetOrders = false)
private fun applyConfiguration(
posConfig: PosConfig,
currency: String,
currencySpec: CurrencySpecification?,
resetOrders: Boolean,
): String?
{
val existingProductsByStableKey = productsById.values.associateBy { it.stableKey }
val selectedCategoryId = if (resetOrders) ALL_PRODUCTS_CATEGORY_ID else currentCategory?.id
val allProductsCategory =
Category(
ALL_PRODUCTS_CATEGORY_ID,
context.getString(R.string.product_category_all_objects),
)
val uncategorizedCategory =
Category(
UNCATEGORIZED_CATEGORY_ID,
context.getString(R.string.product_category_uncategorized),
)
val visibleCategories = posConfig.categories.filterNot(::isLegacyDefaultCategory)
val legacyDefaultCategoryIds =
posConfig.categories
.filter(::isLegacyDefaultCategory)
.map { it.id }
.toSet()
productsByCategory.clear()
productsById.clear()
productsByCategory[allProductsCategory] = ArrayList()
visibleCategories.forEach { category ->
category.selected = false
productsByCategory[category] = ArrayList()
}
productsByCategory[uncategorizedCategory] = ArrayList()
posConfig.products.forEach { product ->
val productCurrency = product.price.currency
val currencyMismatch = productCurrency != currency
if (currencyMismatch)
{
Log.w(TAG, "Product $product has currency $productCurrency, $currency expected")
}
val remainingStock = product.stockLimit
val productWithSpec =
product.copy(
id = existingProductsByStableKey[product.stableKey]?.id ?: product.id,
price = product.price.withSpec(currencySpec),
availableToSell = !currencyMismatch && (remainingStock == null || remainingStock > 0),
remainingStock = remainingStock,
currencyMismatch = currencyMismatch,
)
productsById[productWithSpec.id] = productWithSpec
productsByCategory.getValue(allProductsCategory).add(productWithSpec)
if (product.categories.isEmpty())
{
productsByCategory.getValue(uncategorizedCategory).add(productWithSpec)
}
product.categories.forEach { categoryId ->
if (categoryId in legacyDefaultCategoryIds)
{
productsByCategory.getValue(uncategorizedCategory).add(productWithSpec)
return@forEach
}
val category = visibleCategories.find { it.id == categoryId }
if (category == null)
{
Log.e(TAG, "Product $product has unknown category $categoryId")
productsByCategory.getValue(uncategorizedCategory).add(productWithSpec)
}
else
{
productsByCategory.getValue(category).add(productWithSpec)
}
}
}
this.currency = currency
this.currencySpec = currencySpec
val categoryList =
buildList {
add(allProductsCategory)
addAll(visibleCategories)
if (productsByCategory.getValue(uncategorizedCategory).isNotEmpty())
{
add(uncategorizedCategory)
}
else
{
productsByCategory.remove(uncategorizedCategory)
}
}
val selectedCategory =
categoryList.firstOrNull { it.id == selectedCategoryId } ?: allProductsCategory
categoryList.forEach { it.selected = it.id == selectedCategory.id }
currentCategory = selectedCategory
mCategories.postValue(categoryList)
mProducts.postValue(getVisibleProducts())
if (resetOrders)
{
orders.clear()
orderCounter = 0
orders[0] = createOrder(0)
mCurrentOrderId.postValue(0)
}
else
{
trimOrdersToStockLimits()
}
return null
}
@UiThread
internal fun getOrder(orderId: Int): LiveOrder =
orders[orderId] ?: throw IllegalArgumentException("Order not found: $orderId")
@UiThread
internal fun nextOrder()
{
val currentId = currentOrderId.value!!
var foundCurrentOrder = false
var nextId: Int? = null
for (orderId in orders.keys)
{
if (foundCurrentOrder)
{
nextId = orderId
break
}
if (orderId == currentId) foundCurrentOrder = true
}
if (nextId == null)
{
nextId = ++orderCounter
orders[nextId] = createOrder(nextId)
}
val currentOrder = order(currentId)
if (currentOrder.isEmpty())
{
orders.remove(currentId)
}
mCurrentOrderId.value = requireNotNull(nextId)
updateVisibleProducts()
}
@UiThread
internal fun previousOrder()
{
val currentId = currentOrderId.value!!
var previousId: Int? = null
var foundCurrentOrder = false
for (orderId in orders.keys)
{
if (orderId == currentId)
{
foundCurrentOrder = true
break
}
previousId = orderId
}
if (previousId == null ||
!foundCurrentOrder)
{
throw AssertionError("Could not find previous order for $currentId")
}
val currentOrder = order(currentId)
if (currentOrder.isEmpty())
{
orders.remove(currentId)
}
mCurrentOrderId.value = requireNotNull(previousId)
updateVisibleProducts()
}
fun hasPreviousOrder(currentOrderId: Int): Boolean = currentOrderId != orders.keys.first()
fun hasNextOrder(currentOrderId: Int) =
order(currentOrderId).restartState.map { state ->
state == ENABLED || currentOrderId != orders.keys.last()
}
internal fun setCurrentCategory(category: Category)
{
currentCategory = category
val currentCategories = categories.value.orEmpty()
val newCategories =
currentCategories.map { existing ->
existing.copy().also { copied ->
copied.selected = existing.id == category.id
}
}
currentCategory = newCategories.firstOrNull { it.id == category.id } ?: category
mCategories.postValue(newCategories)
updateVisibleProducts()
}
@UiThread
internal fun addProduct(
orderId: Int,
product: ConfigProduct,
)
{
order(orderId).addProduct(product)
}
@UiThread
internal fun debugSeedCurrentOrder(productIds: List<String>)
{
val orderId = currentOrderId.value ?: return
val liveOrder = order(orderId)
productIds.forEach { productId ->
productsById[productId]?.let(liveOrder::addProduct)
}
updateVisibleProducts()
}
@UiThread
internal fun onOrderPaid(orderId: Int)
{
if (currentOrderId.value == orderId)
{
if (hasPreviousOrder(orderId))
{
previousOrder()
}
else
{
nextOrder()
}
}
orders.remove(orderId)
updateVisibleProducts()
}
@UiThread
internal fun deleteCurrentOrder()
{
val currentId = currentOrderId.value ?: return
val orderIds = orders.keys.toList()
val currentIndex = orderIds.indexOf(currentId)
if (currentIndex == -1) return
orders.remove(currentId)
val replacementId =
when
{
orders.isEmpty() ->
{
orders[currentId] = createOrder(currentId)
currentId
}
currentIndex < orderIds.lastIndex ->
{
orderIds[currentIndex + 1]
}
else ->
{
orderIds[currentIndex - 1]
}
}
mCurrentOrderId.value = replacementId
updateVisibleProducts()
}
private fun order(orderId: Int): MutableLiveOrder = orders[orderId] ?: throw IllegalStateException()
private fun isLegacyDefaultCategory(category: Category): Boolean =
category.name.equals(LEGACY_DEFAULT_CATEGORY_NAME, ignoreCase = true)
private fun createOrder(orderId: Int): MutableLiveOrder =
MutableLiveOrder(
orderId,
currency,
currencySpec,
productsByCategory,
::canAddProduct,
::updateVisibleProducts,
)
private fun getVisibleProducts(): List<ConfigProduct>
{
val category = currentCategory ?: return emptyList()
return productsByCategory[category].orEmpty().map(::decorateProduct)
}
private fun updateVisibleProducts()
{
mProducts.postValue(getVisibleProducts())
}
private fun decorateProduct(product: ConfigProduct): ConfigProduct
{
val remainingStock = remainingStock(product)
return product.copy(
availableToSell = !product.currencyMismatch && (remainingStock == null || remainingStock > 0),
remainingStock = remainingStock,
)
}
private fun canAddProduct(product: ConfigProduct): Boolean = remainingStock(product)?.let { it > 0 } ?: true
private fun remainingStock(product: ConfigProduct): Int?
{
val stockLimit = productsById[product.id]?.stockLimit ?: product.stockLimit ?: return null
val reserved =
orders.values.sumOf { liveOrder ->
liveOrder.order.value
?.products
?.find { it.id == product.id }
?.quantity
?: 0
}
return (stockLimit - reserved).coerceAtLeast(0)
}
private fun trimOrdersToStockLimits()
{
for (liveOrder in orders.values)
{
val order = liveOrder.order.value ?: continue
var modified = false
val trimmedProducts =
order.products.mapNotNull { orderProduct ->
val stockLimit = productsById[orderProduct.id]?.stockLimit ?: return@mapNotNull orderProduct
if (orderProduct.quantity <= stockLimit) return@mapNotNull orderProduct
modified = true
if (stockLimit <= 0)
{
null
}
else
{
orderProduct.copy(quantity = stockLimit)
}
}
if (modified)
{
liveOrder.order.postValue(order.copy(products = trimmedProducts))
}
}
}
}