Hook selectors into the price computation path
[aquarium] / src / main / scala / gr / grnet / aquarium / charging / ChargingBehavior.scala
index 4c252e1..99e9d39 100644 (file)
 
 package gr.grnet.aquarium.charging
 
+import scala.collection.immutable
+import scala.collection.mutable
+
 import gr.grnet.aquarium.event.model.resource.ResourceEventModel
-import gr.grnet.aquarium.{AquariumInternalError, AquariumException}
+import gr.grnet.aquarium.{Aquarium, AquariumInternalError, AquariumException}
+import gr.grnet.aquarium.policy.{FullPriceTable, EffectivePriceTable, UserAgreementModel, ResourceType}
+import com.ckkloverdos.key.{TypedKey, TypedKeySkeleton}
+import gr.grnet.aquarium.util._
+import gr.grnet.aquarium.util.date.TimeHelpers
+import gr.grnet.aquarium.charging.wallet.WalletEntry
+import gr.grnet.aquarium.computation.{TimeslotComputations, BillingMonthInfo}
+import gr.grnet.aquarium.charging.state.AgreementHistory
+import gr.grnet.aquarium.logic.accounting.dsl.Timeslot
+import gr.grnet.aquarium.store.PolicyStore
+import gr.grnet.aquarium.charging.ChargingBehavior.EnvKeys
 
 /**
  * A charging behavior indicates how charging for a resource will be done
@@ -45,9 +58,356 @@ import gr.grnet.aquarium.{AquariumInternalError, AquariumException}
  * @author Christos KK Loverdos <loverdos@gmail.com>
  */
 
-abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput]) {
+abstract class ChargingBehavior(
+    final val alias: String,
+    final val inputs: Set[ChargingInput],
+    final val selectorHierarchy: List[List[String]] = Nil) extends Loggable {
+
+  final val inputNames = inputs.map(_.name)
+
+  @inline private[this] def hrs(millis: Double) = {
+    val hours = millis / 1000 / 60 / 60
+    val roundedHours = hours
+    roundedHours
+  }
+
+  protected def computeCreditsToSubtract(
+      oldCredits: Double,
+      oldAccumulatingAmount: Double,
+      newAccumulatingAmount: Double,
+      timeDeltaMillis: Long,
+      previousValue: Double,
+      currentValue: Double,
+      unitPrice: Double,
+      details: Map[String, String]
+  ): (Double, String) = {
+    alias match {
+     case ChargingBehaviorAliases.continuous ⇒
+       val credits = hrs(timeDeltaMillis) * oldAccumulatingAmount * unitPrice
+       val explanation = "Time(%s) * OldTotal(%s) * Unit(%s)".format(
+         hrs(timeDeltaMillis),
+         oldAccumulatingAmount,
+         unitPrice
+       )
+
+       (credits, explanation)
+
+     case ChargingBehaviorAliases.discrete ⇒
+       val credits = currentValue * unitPrice
+       val explanation = "Value(%s) * Unit(%s)".format(currentValue, unitPrice)
+
+       (credits, explanation)
+
+     case ChargingBehaviorAliases.vmtime ⇒
+       val credits = hrs(timeDeltaMillis) * unitPrice
+       val explanation = "Time(%s) * Unit(%s)".format(hrs(timeDeltaMillis), unitPrice)
+
+       (credits, explanation)
+
+     case ChargingBehaviorAliases.once ⇒
+       val credits = currentValue
+       val explanation = "Value(%s)".format(currentValue)
+
+       (credits, explanation)
+
+     case name ⇒
+       throw new AquariumInternalError("Cannot compute credit diff for charging behavior %s".format(name))
+    }
+  }
+
+  protected def rcDebugInfo(rcEvent: ResourceEventModel) = {
+    rcEvent.toDebugString
+  }
+
+  protected def computeSelectorPath(
+      chargingData: mutable.Map[String, Any],
+      currentResourceEvent: ResourceEventModel,
+      referenceTimeslot: Timeslot,
+      previousValue: Double,
+      totalCredits: Double,
+      oldAccumulatingAmount: Double,
+      newAccumulatingAmount: Double
+  ): List[String]
+
+  /**
+   *
+   * @param chargingData
+   * @param previousResourceEventOpt
+   * @param currentResourceEvent
+   * @param billingMonthInfo
+   * @param referenceTimeslot
+   * @param resourceType
+   * @param agreementByTimeslot
+   * @param previousValue
+   * @param totalCredits
+   * @param policyStore
+   * @param walletEntryRecorder
+   * @return The number of wallet entries recorded and the new total credits
+   */
+  protected def computeChargeslots(
+      chargingData: mutable.Map[String, Any],
+      previousResourceEventOpt: Option[ResourceEventModel],
+      currentResourceEvent: ResourceEventModel,
+      billingMonthInfo: BillingMonthInfo,
+      referenceTimeslot: Timeslot,
+      resourceType: ResourceType,
+      agreementByTimeslot: immutable.SortedMap[Timeslot, UserAgreementModel],
+      previousValue: Double,
+      totalCredits: Double,
+      policyStore: PolicyStore,
+      walletEntryRecorder: WalletEntry ⇒ Unit
+  ): (Int, Double) = {
+
+    val currentValue = currentResourceEvent.value
+    val userID = currentResourceEvent.userID
+    val currentDetails = currentResourceEvent.details
+
+    var _oldAccumulatingAmount = getChargingData(
+      chargingData,
+      EnvKeys.ResourceInstanceAccumulatingAmount
+    ).getOrElse(getResourceInstanceInitialAmount)
+
+    var _oldTotalCredits = totalCredits
+
+    var _newAccumulatingAmount = this.computeNewAccumulatingAmount(_oldAccumulatingAmount, currentValue, currentDetails)
+    setChargingData(chargingData, EnvKeys.ResourceInstanceAccumulatingAmount, _newAccumulatingAmount)
+
+    val policyByTimeslot = policyStore.loadAndSortPoliciesWithin(
+      referenceTimeslot.from.getTime,
+      referenceTimeslot.to.getTime
+    )
+
+    val effectivePriceTableSelector: FullPriceTable ⇒ EffectivePriceTable = fullPriceTable ⇒ {
+      this.selectEffectivePriceTable(
+        fullPriceTable,
+        chargingData,
+        currentResourceEvent,
+        referenceTimeslot,
+        previousValue,
+        totalCredits,
+        _oldAccumulatingAmount,
+        _newAccumulatingAmount
+      )
+    }
+
+    val initialChargeslots = TimeslotComputations.computeInitialChargeslots(
+      referenceTimeslot,
+      resourceType,
+      policyByTimeslot,
+      agreementByTimeslot,
+      effectivePriceTableSelector
+    )
+
+    val fullChargeslots = initialChargeslots.map {
+      case chargeslot@Chargeslot(startMillis, stopMillis, unitPrice, _, _) ⇒
+        val timeDeltaMillis = stopMillis - startMillis
+
+        val (creditsToSubtract, explanation) = this.computeCreditsToSubtract(
+          _oldTotalCredits,       // FIXME ??? Should recalculate ???
+          _oldAccumulatingAmount, // FIXME ??? Should recalculate ???
+          _newAccumulatingAmount, // FIXME ??? Should recalculate ???
+          timeDeltaMillis,
+          previousValue,
+          currentValue,
+          unitPrice,
+          currentDetails
+        )
+
+        val newChargeslot = chargeslot.copyWithCreditsToSubtract(creditsToSubtract, explanation)
+        newChargeslot
+    }
+
+    if(fullChargeslots.length == 0) {
+      throw new AquariumInternalError("No chargeslots computed for resource event %s".format(currentResourceEvent.id))
+    }
+
+    val sumOfCreditsToSubtract = fullChargeslots.map(_.creditsToSubtract).sum
+    val newTotalCredits = _oldTotalCredits - sumOfCreditsToSubtract
 
-  final lazy val inputNames = inputs.map(_.name)
+    val newWalletEntry = WalletEntry(
+      userID,
+      sumOfCreditsToSubtract,
+      _oldTotalCredits,
+      newTotalCredits,
+      TimeHelpers.nowMillis(),
+      referenceTimeslot,
+      billingMonthInfo.year,
+      billingMonthInfo.month,
+      fullChargeslots,
+      previousResourceEventOpt.map(List(_, currentResourceEvent)).getOrElse(List(currentResourceEvent)),
+      resourceType,
+      currentResourceEvent.isSynthetic
+    )
+
+    logger.debug("newWalletEntry = {}", newWalletEntry.toJsonString)
+
+    walletEntryRecorder.apply(newWalletEntry)
+
+    (1, newTotalCredits)
+  }
+
+  protected def removeChargingData[T: Manifest](
+      chargingData: mutable.Map[String, Any],
+      envKey: TypedKey[T]
+  ) = {
+
+    chargingData.remove(envKey.name).asInstanceOf[Option[T]]
+  }
+
+  protected def getChargingData[T: Manifest](
+      chargingData: mutable.Map[String, Any],
+      envKey: TypedKey[T]
+  ) = {
+
+    chargingData.get(envKey.name).asInstanceOf[Option[T]]
+  }
+
+  protected def setChargingData[T: Manifest](
+      chargingData: mutable.Map[String, Any],
+      envKey: TypedKey[T],
+      value: T
+  ) = {
+
+    chargingData(envKey.name) = value
+  }
+
+  def selectEffectivePriceTable(
+      fullPriceTable: FullPriceTable,
+      chargingData: mutable.Map[String, Any],
+      currentResourceEvent: ResourceEventModel,
+      referenceTimeslot: Timeslot,
+      previousValue: Double,
+      totalCredits: Double,
+      oldAccumulatingAmount: Double,
+      newAccumulatingAmount: Double
+  ): EffectivePriceTable = {
+
+    val selectorPath = computeSelectorPath(
+      chargingData,
+      currentResourceEvent,
+      referenceTimeslot,
+      previousValue,
+      totalCredits,
+      oldAccumulatingAmount,
+      newAccumulatingAmount
+    )
+
+    fullPriceTable.effectivePriceTableOfSelectorForResource(selectorPath, currentResourceEvent.safeResource)
+  }
+
+  /**
+   *
+   * @param aquarium
+   * @param currentResourceEvent
+   * @param resourceType
+   * @param billingMonthInfo
+   * @param previousResourceEventOpt
+   * @param userAgreements
+   * @param chargingData
+   * @param totalCredits
+   * @param walletEntryRecorder
+   * @param clogOpt
+   * @return The number of wallet entries recorded and the new total credits
+   */
+  def chargeResourceEvent(
+      aquarium: Aquarium,
+      currentResourceEvent: ResourceEventModel,
+      resourceType: ResourceType,
+      billingMonthInfo: BillingMonthInfo,
+      previousResourceEventOpt: Option[ResourceEventModel],
+      userAgreements: AgreementHistory,
+      chargingData: mutable.Map[String, Any],
+      totalCredits: Double,
+      walletEntryRecorder: WalletEntry ⇒ Unit,
+      clogOpt: Option[ContextualLogger] = None
+  ): (Int, Double) = {
+
+    val clog = ContextualLogger.fromOther(clogOpt, logger, "chargeResourceEvent(%s)", currentResourceEvent.id)
+    val currentResourceEventDebugInfo = rcDebugInfo(currentResourceEvent)
+
+    val isBillable = this.isBillableEvent(currentResourceEvent)
+    val retval = if(!isBillable) {
+      // The resource event is not billable.
+      clog.debug("Ignoring not billable %s", currentResourceEventDebugInfo)
+      (0, totalCredits)
+    } else {
+      // The resource event is billable.
+      // Find the previous event if needed.
+      // This is (potentially) needed to calculate new credit amount and new resource instance amount
+      if(this.needsPreviousEventForCreditAndAmountCalculation) {
+        if(previousResourceEventOpt.isDefined) {
+          val previousResourceEvent = previousResourceEventOpt.get
+          val previousValue = previousResourceEvent.value
+
+          clog.debug("I have previous event %s", previousResourceEvent.toDebugString)
+
+          computeChargeslots(
+            chargingData,
+            previousResourceEventOpt,
+            currentResourceEvent,
+            billingMonthInfo,
+            Timeslot(previousResourceEvent.occurredMillis, currentResourceEvent.occurredMillis),
+            resourceType,
+            userAgreements.agreementByTimeslot,
+            previousValue,
+            totalCredits,
+            aquarium.policyStore,
+            walletEntryRecorder
+          )
+        } else {
+          // We do not have the needed previous event, so this must be the first resource event of its kind, ever.
+          // Let's see if we can create a dummy previous event.
+          val actualFirstEvent = currentResourceEvent
+
+          // FIXME: Why && ?
+          if(this.isBillableFirstEvent(actualFirstEvent) && this.mustGenerateDummyFirstEvent) {
+            clog.debug("First event of its kind %s", currentResourceEventDebugInfo)
+
+            val dummyFirst = this.constructDummyFirstEventFor(currentResourceEvent, billingMonthInfo.monthStartMillis)
+            clog.debug("Dummy first event %s", dummyFirst.toDebugString)
+
+            val previousResourceEvent = dummyFirst
+            val previousValue = previousResourceEvent.value
+
+            computeChargeslots(
+              chargingData,
+              Some(previousResourceEvent),
+              currentResourceEvent,
+              billingMonthInfo,
+              Timeslot(previousResourceEvent.occurredMillis, currentResourceEvent.occurredMillis),
+              resourceType,
+              userAgreements.agreementByTimeslot,
+              previousValue,
+              totalCredits,
+              aquarium.policyStore,
+              walletEntryRecorder
+            )
+          } else {
+            clog.debug("Ignoring first event of its kind %s", currentResourceEventDebugInfo)
+            // userStateWorker.updateIgnored(currentResourceEvent)
+            (0, totalCredits)
+          }
+        }
+      } else {
+        // No need for previous event. One event does it all.
+        computeChargeslots(
+          chargingData,
+          None,
+          currentResourceEvent,
+          billingMonthInfo,
+          Timeslot(currentResourceEvent.occurredMillis, currentResourceEvent.occurredMillis + 1),
+          resourceType,
+          userAgreements.agreementByTimeslot,
+          this.getResourceInstanceUndefinedAmount,
+          totalCredits,
+          aquarium.policyStore,
+          walletEntryRecorder
+        )
+      }
+    }
+
+    retval
+  }
 
   /**
    * Generate a map where the key is a [[gr.grnet.aquarium.charging.ChargingInput]]
@@ -88,8 +448,6 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
       unitPrice)
   }
 
-  def isNamed(aName: String): Boolean = aName == alias
-
   def needsPreviousEventForCreditAndAmountCalculation: Boolean = {
     // If we need any variable that is related to the previous event
     // then we do need a previous event
@@ -97,21 +455,24 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
   }
 
   /**
-   * Given the old amount of a resource instance
-   * (see [[gr.grnet.aquarium.computation.state.parts.ResourceInstanceSnapshot]]), the
-   * value arriving in a new resource event and the new details, compute the new instance amount.
+   * Given the old amount of a resource instance, the value arriving in a new resource event and the new details,
+   * compute the new instance amount.
    *
    * Note that the `oldAmount` does not make sense for all types of [[gr.grnet.aquarium.charging.ChargingBehavior]],
    * in which case it is ignored.
    *
-   * @param oldAmount     the old accumulating amount
+   * @param oldAccumulatingAmount     the old accumulating amount
    * @param newEventValue the value contained in a newly arrived
    *                      [[gr.grnet.aquarium.event.model.resource.ResourceEventModel]]
-   * @param details       the `details` of the newly arrived
+   * @param newDetails       the `details` of the newly arrived
    *                      [[gr.grnet.aquarium.event.model.resource.ResourceEventModel]]
    * @return
    */
-  def computeNewAccumulatingAmount(oldAmount: Double, newEventValue: Double, details: Map[String, String]): Double
+  def computeNewAccumulatingAmount(
+      oldAccumulatingAmount: Double,
+      newEventValue: Double,
+      newDetails: Map[String, String]
+  ): Double
 
   /**
    * The initial amount.
@@ -125,7 +486,7 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
    *
    * Normally, this value will never be used by client code (= charge computation code).
    */
-  def getResourceInstanceUndefinedAmount: Double = -1.0
+  def getResourceInstanceUndefinedAmount: Double = Double.NaN
 
   /**
    * An event carries enough info to characterize it as billable or not.
@@ -133,9 +494,9 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
    * Typically all events are billable by default and indeed this is the default implementation
    * provided here.
    *
-   * The only exception to the rule is ON events for [[gr.grnet.aquarium.charging.OnOffChargingBehavior]].
+   * The only exception to the rule is ON events for [[gr.grnet.aquarium.charging.VMChargingBehavior]].
    */
-  def isBillableEvent(event: ResourceEventModel): Boolean = false
+  def isBillableEvent(event: ResourceEventModel): Boolean = true
 
   /**
    * This is called when we have the very first event for a particular resource instance, and we want to know
@@ -145,11 +506,11 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
 
   def mustGenerateDummyFirstEvent: Boolean
 
-  def dummyFirstEventValue: Double = 0.0
+  def dummyFirstEventValue: Double = 0.0 // FIXME read from configuration
 
   def constructDummyFirstEventFor(actualFirst: ResourceEventModel, newOccurredMillis: Long): ResourceEventModel = {
     if(!mustGenerateDummyFirstEvent) {
-      throw new AquariumException("constructDummyFirstEventFor() Not compliant with %s".format(this))
+      throw new AquariumInternalError("constructDummyFirstEventFor() Not compliant with %s", this)
     }
 
     val newDetails = Map(
@@ -166,7 +527,7 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
    * There are resources (cost policies) for which implicit events must be generated at the end of the billing period
    * and also at the beginning of the next one. For these cases, this method must return `true`.
    *
-   * The motivating example comes from the [[gr.grnet.aquarium.charging.OnOffChargingBehavior]] for which we
+   * The motivating example comes from the [[gr.grnet.aquarium.charging.VMChargingBehavior]] for which we
    * must implicitly assume `OFF` events at the end of the billing period and `ON` events at the beginning of the next
    * one.
    *
@@ -180,6 +541,15 @@ abstract class ChargingBehavior(val alias: String, val inputs: Set[ChargingInput
 }
 
 object ChargingBehavior {
+  final case class ChargingBehaviorKey[T: Manifest](override val name: String) extends TypedKeySkeleton[T](name)
+
+  /**
+   * Keys used to save information between calls of `chargeResourceEvent`
+   */
+  object EnvKeys {
+    final val ResourceInstanceAccumulatingAmount = ChargingBehaviorKey[Double]("resource.instance.accumulating.amount")
+  }
+
   def makeValueMapFor(
       chargingBehavior: ChargingBehavior,
       totalCredits: Double,
@@ -195,13 +565,13 @@ object ChargingBehavior {
     var map = Map[ChargingInput, Any]()
 
     if(inputs contains ChargingBehaviorNameInput) map += ChargingBehaviorNameInput -> chargingBehavior.alias
-    if(inputs contains TotalCreditsInput  ) map += TotalCreditsInput   -> totalCredits
-    if(inputs contains OldTotalAmountInput) map += OldTotalAmountInput -> oldTotalAmount
-    if(inputs contains NewTotalAmountInput) map += NewTotalAmountInput -> newTotalAmount
-    if(inputs contains TimeDeltaInput     ) map += TimeDeltaInput      -> timeDelta
-    if(inputs contains PreviousValueInput ) map += PreviousValueInput  -> previousValue
-    if(inputs contains CurrentValueInput  ) map += CurrentValueInput   -> currentValue
-    if(inputs contains UnitPriceInput     ) map += UnitPriceInput      -> unitPrice
+    if(inputs contains TotalCreditsInput        ) map += TotalCreditsInput   -> totalCredits
+    if(inputs contains OldTotalAmountInput      ) map += OldTotalAmountInput -> oldTotalAmount
+    if(inputs contains NewTotalAmountInput      ) map += NewTotalAmountInput -> newTotalAmount
+    if(inputs contains TimeDeltaInput           ) map += TimeDeltaInput      -> timeDelta
+    if(inputs contains PreviousValueInput       ) map += PreviousValueInput  -> previousValue
+    if(inputs contains CurrentValueInput        ) map += CurrentValueInput   -> currentValue
+    if(inputs contains UnitPriceInput           ) map += UnitPriceInput      -> unitPrice
 
     map
   }