Dienstag, 23. Februar 2010

Angebotskalkulator - Präsentation

Sodala, vorletzte Woche habe ich den Angebotskalkulator vorerst fertiggestellt.

Die Features:
  • Preise für einen Transport werden automatisch berechnet
  • Benutzer gibt Start-, Ziel- und Viapunkte vor
  • Adresswahl entweder durch Eingabe oder durch Doppelklick auf Karte
  • Start-, Ziel- und Zwischenpunkte können mittels Drag&Drop auf der Karte verschoben werden
  • Teilladungspreise können berechnet werden
  • Regelsystem wird in XML Datei gespeichert und ist somit leicht austausch- und optimierbar
  • Durch Integration in mein Nothegger Butler System stehen Standardfunktionen wie beispielsweise Auto-Update oder automatische Sende von Fehlerberichten an mich zur Verfügung
  • Der Benutzer hat die Möglichkeit eigene Wünsche als Feature-Request direkt an mich zu schicken
  • Oft verwendete VIA Punkte (Mont Blanc, Frejus, etc.) sind als Bookmarks unterhalb der Karte hinterlegt
  • Ortsuche entweder über komplette Postleitzahl, oder über eine Kombination aus Postleitzahlenbereich und Stichwörtern
  • Vom Geocoder gefundene Adressen werden gespeichert, sodass bei erneuter Verwendung (entweder durch den selben oder andere Benutzer) die Adresse nicht erneut vom Geocoder bezogen werden muß
  • Sehr intuitives Handling der Karte
  • Anzeige eines absoluten Minimumpreises, der nur in wenigen Ausnahmefällen unterschritten werden darf
Screenshots (Klick auf Bild vergrößert das Bild):

Das Programm in Aktion:


Via-Punkte können einfach durch Doppelklick auf die Karte hinzugefügt werden:


Fährt man mit dem Mauszeiger über einen Marker, so wird er hervorgehoben dargestellt und man kann ihn mittels Drag&Drop verschieben.


Nach der Berechnung kann man sich die Berechnungsdetails ansehen. Dabei ist der "Durchlauf" durch das Regelsystem nachvollziehbar, da ersichtlich ist, welche Regeln greifen und welche damit verbundenen Aktionen ausgeführt wurden.


Entspricht das berechnete Ergebnis nicht den Vorstellungen des Disponenten, so kann er mir dies über einen Klick und Angabe einer Begründung auf einfache Art und Weise mitteilen. Ich halte dann mit dem Chef Rücksprache und passe das Regelsystem ggf. an.


Das Programm ist jetzt seit ca. eineinhalb Wochen im Einsatz und die Resonanz ist sehr positiv.

Jetzt kann ich mich dann wieder anderen Dingen widmen. Das Senden von Ladestellen über den Nothegger Butler ist eines der nächsten Dinge. Zuvor werd ich aber noch ein kleines Tool schreiben, das unserer Buchhaltung hilft, Vorsteueranträge an das Finanzministerium zu übermitteln. Mehr dazu zu einem späteren Zeitpunkt.

Freitag, 5. Februar 2010

Angebotskalkulation - Regelsystem

Nun habe ich mich der Berechnung des Angebotspreises gewidmet. Die Preise sind nämlich von verschiedenen Faktoren (wie zum Beispiel Ausgangsland, Zielland, Wegpunkte etc.) anhängig. Die Preise fix in das Programm zu kodieren wäre sehr unelegant. Aber wie kann man so ein Berechnungssystem möglichst flexibel und trotzdem nicht fix kodiert programmieren? Ich sah von Anfang an zwei vielversprechende Möglichkeiten. Entweder mit einer Scriptsprache (z.B. Script.NET, hab ich schon in meiner Diplomarbeit verwendet) oder über eine XML Datei, in der die Regeln kodiert sind.
Ich hab mich jetzt für die Variante mit der XML Datei entschieden. Dabei soll es verschiedene Rulesets in der Datei geben. Zu jedem RuleSet gehören eine Menge von Regeln. Pro RuleSet kann man sich festlegen, ob man jede Regel ausführen will (evaluate="ALL") oder ob der Interpreter (wie beispielsweise bei iptables) die Regeln nur so lange abarbeiten soll, bis es zu einem Treffer kommt (evaluate="UNTIL_FIRST_HIT"). Wobei erster Treffer nicht ganz zutreffend ist. Jeder Regel sind in meinem Schema nämlich entweder eine variable Anzahl von Kindregeln oder eine Menge von mit der Regel verknüpften Aktionen zugeordnet. Sobald eine Regel erfüllt wurde und ihre Aktionen fertig ausgeführt wurden, wird das in meiner Terminilogie als FIRST_FIT betrachtet.
Das XML Schema für das Regelsystem sieht wie folgt aus:

<?xml version="1.0" encoding="ISO-8859-1"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
<xs:element name="pricing_definition" type="rulesets_Type"/>
<xs:complexType name="rulesets_Type">
<xs:sequence>
<xs:choice maxOccurs="unbounded">
<xs:element name="pricetable" type="pricetable_Type"/>
</xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="geoshape" type="geoshape_Type"/>
</xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="actionset" type="actionset_Type"/>
</xs:choice>
<xs:choice maxOccurs="unbounded">
<xs:element name="ruleset" type="ruleset_Type"/>
</xs:choice>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ruleset_Type">
<xs:sequence maxOccurs="unbounded">
<xs:element name="rule" type="rule_Type"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="evaluate" type="evaluate_type" use="required"/>
</xs:complexType>
<xs:complexType name="rule_Type">
<xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="rule" type="rule_Type"/>
</xs:choice>
<xs:sequence>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="action" type="action_Type"/>
<xs:element name="execute" type="execute_Type"/>
</xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="partial_cargo_pricing" type="partial_cargo_pricing_Type"/>
</xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="panic" type="panic_Type"/>
</xs:choice>
</xs:sequence>
</xs:choice>
<xs:attribute name="match_target" type="match_target_type" use="required"/>
<xs:attribute name="match_type" type="match_type" use="required"/>
<xs:attribute name="match_value" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="execute_Type">
<xs:attribute name="actionset" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="actionset_Type">
<xs:choice>
<xs:sequence>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="action" type="action_Type"/>
<xs:element name="execute" type="execute_Type"/>
</xs:choice>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="partial_cargo_pricing" type="partial_cargo_pricing_Type"/>
</xs:choice>
</xs:sequence>
</xs:choice>
<xs:attribute name="id" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="panic_Type">
<xs:attribute name="desc" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="action_Type">
<xs:attribute name="target" type="action_target_type" use="required"/>
<xs:attribute name="type" type="action_type" use="required"/>
<xs:attribute name="value" type="xs:string" use="required"/>
</xs:complexType>
<xs:simpleType name="match_target_type">
<xs:restriction base="xs:string">
<xs:enumeration value="SRC_COUNTRY"/>
<xs:enumeration value="DST_COUNTRY"/>
<xs:enumeration value="SRC_ZIP"/>
<xs:enumeration value="DST_ZIP"/>
<xs:enumeration value="ROUTE"/>
<xs:enumeration value="TRUCKTYPE"/>
<xs:enumeration value="PRICE"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="match_type">
<xs:restriction base="xs:string">
<xs:enumeration value="EQUALS"/>
<xs:enumeration value="STARTS_WITH"/>
<xs:enumeration value="ENDS_WITH"/>
<xs:enumeration value="GREATER"/>
<xs:enumeration value="SMALLER"/>
<xs:enumeration value="PASSES"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="action_target_type">
<xs:restriction base="xs:string">
<xs:enumeration value="PRICE"/>
<xs:enumeration value="MIN_PRICE"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="action_type">
<xs:restriction base="xs:string">
<xs:enumeration value="ADD_ABS"/>
<xs:enumeration value="ADD_REL"/>
<xs:enumeration value="SET"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="evaluate_type">
<xs:restriction base="xs:string">
<xs:enumeration value="ALL"/>
<xs:enumeration value="UNTIL_FIRST_FIT"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="pricetable_Type">
<xs:choice maxOccurs="unbounded">
<xs:element name="pte" type="pricetable_entry_Type"/>
</xs:choice>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="pricing" type="pricing_type" use="required"/>
<xs:attribute name="entity_size" type="xs:double" use="required"/>
</xs:complexType>
<xs:complexType name="pricetable_entry_Type">
<xs:attribute name="count" type="xs:integer" use="required"/>
<xs:attribute name="percentage" type="xs:double" use="required"/>
</xs:complexType>
<xs:simpleType name="pricing_type">
<xs:restriction base="xs:string">
<xs:enumeration value="OVERALL_PERCENTAGE"/>
<xs:enumeration value="PER_ENTITY_PERCENTAGE"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="partial_cargo_pricing_Type">
<xs:attribute name="ldm_table" type="xs:string" use="required"/>
<xs:attribute name="pal_table" type="xs:string" use="required"/>
<xs:attribute name="weight_table" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="geoshape_Type">
<xs:choice>
<xs:element name="geocircle" type="geocircle_Type"/>
</xs:choice>
<xs:attribute name="id" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="geocircle_Type">
<xs:sequence>
<xs:element name="center_lat" type="xs:double"/>
<xs:element name="center_lng" type="xs:double"/>
<xs:element name="radius" type="xs:double"/>
</xs:sequence>
</xs:complexType>
</xs:schema>



PriceTables werden benötigt, damit man, damit man für Teilladungen den prpzentuellen Teilladungspreis berechnen kann. So wird beispielsweise eine Preistabelle mit dem Namen LDM_A definiert. OVERALL_PERCENTAGE gibt an, dass es sich um absolute prozentuelle Werte handelt. Alternativ dazu könnte man PER_ENTITY_PERCENTAGE angeben. Dann würde der Preis pro Entity (also pro Ladungseinheit) berechnet werden. Dazu würde man das Attribut entity_size benötigen.

<pricetable id="LDM_A" pricing="OVERALL_PERCENTAGE" entity_size="1">
<pte percentage="16" count="1"></pte>


Als nächstes werden sogenannte GeoShapes definiert. Ein GeoShape ist ein Bereich auf der Landkarte. Man kann dann in den Regeln überprüfen, ob eine Route durch ein bestimmtes GeoShape verläuft. Ein GeoShape wird folgendermaßen definiert:

<geoshape id="INNSBRUCK">
<geocircle>
<center_lat>47.2496400077265</center_lat>
<center_lng>11.3962554931641</center_lng>
<radius>0.17204397565615925</radius>
</geocircle>
</geoshape>


Dabei handelt es sich um einen GeoCircle, d.h. einen Kreis auf der Landkarte mit gegebenem Mittelpunkt und Radius.
Um dem Ersteller einer solchen Regeldatei Schreibarbeit zu ersparen, habe ich das Konzept der ActionSets eingeführt. Dabei kann man eine Menge von Aktionen zu einem ActionSet zusammenfassen und in einer Regel einfach auf das ActionSet verweisen, anstatt alle darin enthaltenen Aktionen extra an die Regel anhängen zu müssen. Ein ActionSet wird wiefolgt definiert:

<actionset id="AS_150_80">
<action type="ADD_ABS" target="PRICE" value="150"/>
<action type="ADD_ABS" target="MIN_PRICE" value="80"/>
</actionset>

Dabei werden dem ActionSet zwei Aktionen zugeordnet. Man könnte jedoch rekursiv weitere ActionSets einbinden:

<actionset id="AS_400_350_W2_LDMA_PAL2">
<execute actionset="AS_400_350" />
<partial_cargo_pricing weight_table="WEIGHT_2" ldm_table="LDM_A" pal_table="PAL_2"/>
</actionset>

Hier wird ein ActionSet mit Hilfe des execute Elementes aufgerufen und zusätzlich eine partial_cargo_pricing Aktion ausgeführt. Eine solche Aktion bestimmt eine der oben definierten Preistabellen für jeden Teilladungstyp (Lademeter, Gewicht oder Paletten).

Danach folgen die einzelnen RuleSets mit ihren Kindregeln. Solche Kindregeln können weitere Kindregeln haben. Ein Beispiel für eine solche Regel sieht man im folgenden Listing:

<ruleset evaluate="UNTIL_FIRST_FIT" name="Main Actions">
<!-- Ladungen in die Schweiz -->
<rule match_target="DST_COUNTRY" match_type="EQUALS" match_value="CH">
<rule match_target="TRUCKTYPE" match_type="EQUALS" match_value="PLANE">
<execute actionset="AS_400_350_W2_LDMA_PAL2"/>
</rule>

...

Die erste Zeile definiert ein RuleSet mit dem Namen "Main Actions" und die Regeln werden so lange ausgeführt, bis man auf eine gültige Aktion trifft.Die erste Regel wird erfüllt, wenn es sich beim Zielland um die Schweiz handelt. Wird diese Regel nicht erfüllt, springt das Auswertungssystem auf die nächste Schwesterregel. Ist die Regel erfüllt, so wird überprüft ob es sich um ein Planenfahrzeug handelt. Ist diese Regel auch erfüllt, so wird das ActionSet AS_400_350_W2_LDMA_PAL2 ausgeführt. Wird sie nicht erfüllt, wird mit der nächsten Schwesterregel fortgefahren. Wird von diesen auch keine erfüllt, so wird eine Ebene höher mit der nächsten Schwesterregel weitergemacht usw. Es handelt sich also im Prinzip um einen DFS Algorithmus.
Interessant ist auch folgende Regel:

<rule match_target="ROUTE" match_type="PASSES" match_value="MONTBLANC">
<action type="ADD_ABS" value="1150" target="PRICE"/>
<partial_cargo_pricing weight_table="WEIGHT_3" pal_table="PAL_2" ldm_table="LDM_B"/>
</rule>


Hierbei wird überprüft, ob die Route über den Mont Blanc verläuft. ist dies der Fall, wird zum Preis für den Transport ein absoluter Betrag hinzugefügt.

Sodala, das wars dann auch wieder mit meinem kleinen Berechnungssystem. Ich finde dass mir so eine gute Alternative zwischen Flexibilität und Wiederverwendbarkeit gelungen ist. Ich hab unser Regelsystem jetzt mal von Hand zusammengestellt und bin auf eine XML Datei mit ca. 1500 Zeilen gekommen. Vielleicht werd ich mir in Zukunft in ein paar ruhigen Minuten einmal einen grafischen Editor programmieren.. wer weiß ?! :-)