Entwicklung eines webbasierten Warenwirtschaftssystems für den Skriptenverkauf der Fachschaft

Systementwicklungsprojekt an der Technischen Universität München, Lehrstuhl für Software Engineering betrieblicher Informationssysteme; Betreuer: Thomas Büchner

Überblick

Ausgangslage

Die Fachschaft Mathematik/Physik/Informatik der Technischen Universität München gliedert sich im wesentlichen in Referate (Arbeitsgruppen), die verschiedene Aufgaben zur Förderung des Studienalltags wahrnehmen. Die folgenden Referate sind für dieses Systementwicklungsprojekt relevant (und umgekehrt):

Das Druckreferat betreibt eine Druckerei, in der im Auftrag des Skriptenreferats Vorlesungsskripten, Klausurensammlungen und Prüfungsprotokolle hergestellt werden. Die Einnahmen aus dem Skriptenverkauf sowie die Ausgaben für die Herstellung der Druckerzeugnisse werden vom Finanzreferat verwaltet.

Vor einigen Jahren gab es anscheinend bereits ein computergestütztes System zur Verwaltung der Bestände und/oder für die buchhalterische Abwicklung der Verkäufe. Genaue Informationen über dieses System sind auf Grund der Fluktuation innerhalb der Fachschaft nicht mehr ohne weiteres erhältlich und wurden ohnehin für dieses Projekt nicht als entscheidend eingestuft. Dem Vernehmen nach endete der Einsatz des alten Systems, als es beim Versuch, seinen Funktionsumfang zu erweitern, versehentlich zerstört wurde.

Bei Beginn dieses Systementwicklungsprojekts wurden Skriptenverkäufe nur in Papierform erfaßt, indem für jeden Verkaufsvorgang eine Zeile mit den entsprechenden Summen in ein Kassenbuchformular eingetragen wurde. Über die Anzahl der vorrätigen Skripten existierte außer unmittelbar nach der jährlichen Inventur kein zentraler Überblick. Insbesondere aus Sicht des Finanzreferats war dieses fehleranfällige Verfahren nicht länger tragbar; außerdem bestand im Skriptenreferat der Wunsch nach einem Werkzeug zur kontinuierlichen Bestandserfassung und Bedarfsabschätzung, um Skripten rechtzeitig nachdrucken zu können.

Anforderungen

In einer Vorbesprechung wurden von Seiten der beteiligten Referate folgende Anforderungen an das System festgelegt:

Anwendungsfälle

Auf Grund der festgestellten Anforderungen wurden folgende Anwendungsfälle identifiziert:

Systemarchitektur

Nach einer Erhebung der verschiedenen Anforderungen stand schnell fest, daß der nötige Funktionsumfang sich leicht mit einer HTML-Oberfläche würde umsetzen lassen, so daß das System nur auf einem Server eingerichtet werden muß und benutzerseitig ein beliebiger Web-Browser ausreicht.

Um zu vermeiden, daß auf Grund der bereits erwähnten Fluktuation in ein paar Jahren wieder eine vergleichbare Situation entsteht wie zu Beginn des Projekts, wurde von Anfang an sehr großer Wert darauf gelegt, das neue System möglichst einfach und übersichtlich aufzubauen. Dafür bot sich eine vereinfachte MVC-Architektur an, bei der die Komponenten Präsentation (also der HTML-Code) und Steuerung (im wesentlichen zuständig für die grundlegende Plausibilitätsprüfung der Benutzereingaben sowie für deren Weitergabe an das Modell, das sämtliche Datenbankzugriffe und die Geschäftslogik enthält) zu einer Einheit zusammengefaßt werden. Durch diese Aufteilung besteht auch weiterhin die Möglichkeit, später mit geringem Aufwand zusätzliche Oberflächen hinzuzufügen, z. B. eine Webservice-Schnittstelle, über die das System dann mit einer speziellen Client-Anwendung benutzt werden könnte.

Für jeden Anwendungsfall wurde eine Präsentations-/Steuerungs-Einheit angelegt. Bei denjenigen Anwendungsfällen, in denen nicht nur lesend auf die Datenbank zugegriffen wird, läuft die Interaktion prinzipiell immer nach demselben Schema ab:

  1. Der Benutzer erhält zunächst (über HTTP GET) das Formular, in dem er die gewünschte Änderung/Ergänzung vornimmt.
  2. Die Änderung wird mittels HTTP POST an die P/S-Einheit übertragen, die auf Grund des Anfragetyps (POST) in den Modus »Steuerung« übergeht, die übertragenen Daten ans Modell weitergibt und dem Client eine HTTP-Weiterleitung zum ursprünglichen Formular zurückschickt.
  3. Anschließend wird dem Benutzer wieder das ursprüngliche Formular – mit den aktualisierten Daten – angezeigt, das er bei Bedarf erneut bearbeiten kann.

Dieser Zwischenschritt (POST-Anfrage, Weiterleitung, GET-Anfrage) ergibt sich fast automatisch aus RFC 2616, Ziffer 9.5.

Programmiersprache und Datenbanksystem

Ebenfalls im Sinne möglichst einfacher Wartbarkeit fiel die Wahl bereits während der Vorbesprechung schnell auf die Kombination von MySQL als Datenbanksystem und PHP als Programmiersprache. Beide sind inzwischen schon seit mehreren Jahren die jeweils am weitesten verbreitete Open-Source-Lösung in ihrem Bereich; die Wahrscheinlichkeit, daß Fachschaftsmitglieder, die später einmal Anpassungen am System vornehmen sollen, sich zunächst an eine neue Programmiersprache gewöhnen müssen, ist hier also am geringsten. Zudem ist auf den Servern der Fachschaft beides bereits installiert (und wird auch für diverse andere Anwendungen genutzt); es entsteht also kein zusätzlicher Installations- und Wartungsaufwand.

Aufbau des Systems

Nachdem die Systemarchitektur und die Wahl der zugrundeliegenden Softwarepakete feststanden, wurde zunächst an Hand der Anforderungen die Struktur der SQL-Datenbank festgelegt; diese erfuhr im Laufe der weiteren Entwicklung nur noch geringfügige Anpassungen (z. B. die Erweiterung um eine zusätzliche Tabelle für alte Kautionsscheine). Anschließend wurden die einzelnen Anwendungsfälle Schritt für Schritt implementiert; hierbei entstanden die Funktionen im Modell (also Datenbankzugriffe und Geschäftslogik) im wesentlichen in der Reihenfolge, in der sie für die jeweilige Präsentations-/Steuerungs-Komponente benötigt wurden.

Programmbeispiel

Im folgenden wird exemplarisch der Programmcode betrachtet, der im Anwendungsfall »Skripten verkaufen« für die Erzeugung des dort gezeigten Verkaufsformulars zuständig ist. Der Übersichtlichkeit halber werden hier einige Programmzeilen ausgelassen, die nur von untergeordneter Bedeutung sind; diese Auslassungen sind jeweils mit [...] gekennzeichnet.

Im Modell kommt hier die Funktion lies_verkaufsvorgang() zum Einsatz, die alle erforderlichen Daten (Status, Verkäufer- und Kundendaten, Artikel und Kautionsscheine) aus der Datenbank liest und in einem Array zusammenstellt.

  1  function lies_verkaufsvorgang($id) {
  2    // Hole die Stammdaten des Verkaufsvorgangs
  3    $abfrage = mysql_query(sprintf("SELECT `id`, UNIX_TIMESTAMP(`abgeschlossen`)
                                         AS `abgeschlossen`,
                                         UNIX_TIMESTAMP(`zeitpunkt`) AS `zeitpunkt`,
                                         `verkaeufer`, `kunde`
                                       FROM `verkaufsvorgang`
                                       WHERE `id` = %u",
                                      (int)$id));
  4  	if(mysql_num_rows($abfrage) == 0) {
  5      return NULL;
  6    } else {
  7      $verkaufsvorgang = mysql_fetch_array($abfrage, MYSQL_ASSOC);
  8      if(isset($verkaufsvorgang["abgeschlossen"])) {
  9        $verkaufsvorgang["zeitpunkt"] = $verkaufsvorgang["abgeschlossen"];
 10        $verkaufsvorgang["abgeschlossen"] = TRUE;
 11      } else {
 12        $verkaufsvorgang["abgeschlossen"] = FALSE;
 13      }
 14      // Hole Namen und Kennungen von Verkäufer und Kunde
 15      [...]
 16      // Hole alle Artikel dieses Verkaufsvorgangs
 17      $abfrage = mysql_query(sprintf("SELECT
                                           `verkaufterartikel`.`artikel` AS `id`,
                                           `artikel`.`titel`,
                                           `verkaufterartikel`.`anzahl`,
                                           `verkaufterartikel`.`bruttopreis`,
                                           `verkaufterartikel`.`ustsatz`
                                         FROM `verkaufterartikel`, `artikel`
                                         WHERE
                                           `verkaufterartikel`.`verkaufsvorgang` = %s
                                           AND `artikel`.`id`
                                             = `verkaufterartikel`.`artikel`
                                         ORDER BY `hinzugefuegt` ASC",
                                        (int)$id));
 18      $artikelnummern = array();
 19      while($artikel = mysql_fetch_assoc($abfrage)) {
 20        $artikel["nettopreis"]
             = $artikel["bruttopreis"] / (1 + ($artikel["ustsatz"])/100);
 21        $verkaufsvorgang["artikel"][] = $artikel;
 22        $artikelnummern[] = "'" . mysql_real_escape_string($artikel["id"]) . "'";
 23      }
 24      $kautionspflicht = array();
 25      $kopiervorlage = array();
 26      // Stelle fest, welche dieser Artikel Protokolle oder Kopiervorlagen sind
 27      if(count($artikelnummern)) {
 28        $abfrage = mysql_query(sprintf("SELECT `artikel`, `fachbereich`,
                                             `pruefungsart`, `kopiervorlage`
                                           FROM `skript`
                                           WHERE `artikel` IN (%s)
                                             AND (`unterart` = 'Protokoll'
                                             OR `kopiervorlage` = 'ja')",
                                          implode(",", $artikelnummern)));
 29        while($skript = mysql_fetch_assoc($abfrage)) {
 30          $kautionspflicht[$skript["artikel"]] = sprintf("%s %s",
                                                            $skript["fachbereich"],
                                                            $skript["pruefungsart"]);
 31          if($skript["kopiervorlage"] == "ja") {
 32            $kopiervorlage[$skript["artikel"]] = TRUE;
 33          }
 34        }
 35      }
 36      if(count($verkaufsvorgang["artikel"])) {
 37        foreach($verkaufsvorgang["artikel"] as $index => $artikel) {
 38          if(isset($kautionspflicht[$artikel["id"]])) {
 39            $verkaufsvorgang["artikel"][$index]["kautionspflicht"]
                 = $kautionspflicht[$artikel["id"]];
 40          } else {
 41            $verkaufsvorgang["artikel"][$index]["kautionspflicht"] = FALSE;
 42          }
 43          if($kopiervorlage[$artikel["id"]]) {
 44            $verkaufsvorgang["artikel"][$index]["kopiervorlage"] = TRUE;
 45          } else {
 46            $verkaufsvorgang["artikel"][$index]["kopiervorlage"] = FALSE;
 47          }
 48        }
 49      }
 50      // Hole alle Kautionsscheine dieses Verkaufsvorgangs
 51      $abfrage = mysql_query(sprintf("SELECT `kautionsschein`.`id`,
                                           `kautionsschein`.`fachbereich`,
                                           `kautionsschein`.`pruefungsart`,
                                           `kautionsschein`.`bruttopreis`
                                         FROM `kautionsscheinverkauf`,
                                           `kautionsschein`
                                         WHERE
                                           `kautionsscheinverkauf`.`verkaufsvorgang`
                                           = %u
                                           AND `kautionsschein`.`id`
                                           =
                                           `kautionsscheinverkauf`.`kautionsnummer`",
                                        (int)$id));
 52      while($kautionsschein = mysql_fetch_assoc($abfrage)) {
 53        $kautionsschein["ustsatz"] = 0;
 54        $kautionsschein["nettopreis"] = $kautionsschein["bruttopreis"];
 55        $verkaufsvorgang["kautionsscheine"][] = $kautionsschein;
 56      }
 57      return $verkaufsvorgang;
 58    }
 59  }

Aufgerufen wird diese Funktion durch die Datei »verkaufsvorgang.php«, die außerdem alle Eingaben des Benutzers an die entsprechenden Funktionen des Modells weiterleitet (dieser Teil wird hier jedoch nicht behandelt). Diese Datei wird über alle URLs nach dem Muster ^intern/verkauf/([0-9]+)/$ aufgerufen; die Verkaufsvorgangsnummer wird hierbei in die Variable $id übergeben.

  1  $verkaufssvorgang = lies_verkaufsvorgang($id);
  2  if(is_null($verkaufsvorgang)) {
  3    header('HTTP/1.0 404 Not Found');
  4    [...]
  5    die;
  6  }
  7  ob_start();
  8  $vorhandene_kautionsscheine = array(); // bereits in diesem Verkaufsvorgang ent-
                                            // haltene Kautionsscheine
  9  if(count($verkaufsvorgang["kautionsscheine"]) > 0) {
 10    foreach($verkaufsvorgang["kautionsscheine"] as $kautionsschein) {
 11      $kautionsschein = $kautionsschein["fachbereich"] . " "
         . $kautionsschein["pruefungsart"];
 12      $vorhandene_kautionsscheine[$kautionsschein] = TRUE;
 13    }
 14  }
 15  $moegliche_kautionsscheine = array(); // derzeit noch relevante Kautionsscheine
 16  if(count($model_kautionsscheine) > 0) {
 17    foreach($model_kautionsscheine as $fachbereichsname => $fachbereich) {
 18      foreach($fachbereich as $pruefungsart) {
 19        $moegliche_kautionsscheine[$fachbereichsname . "::" . $pruefungsart]
           = $fachbereichsname . " " . $pruefungsart;
 20      }
 21    }
 22  }
 23  $fehlende_kautionsscheine = array();
 24  [...]
 25  printf('<h1>Verkaufsvorgang %u</h1>', $id);
 26  printf('<p>Verkäufer: %s (%s)</p>',
            $verkaufsvorgang["verkaeufer"]["name"],
            $verkaufsvorgang["verkaeufer"]["kennung"]);
 27  print('<form name="artikelliste" action="./" method="POST">');
 28  print('<table border="1">');
 29  print('<tr><th>Nr.</th><th>Artikel</th><th>Bezeichnung</th><th>Netto</th>
            <th>USt.</th><th>Brutto</th><th>Anzahl</th><th>Summe</th></tr>');
 30  $zwischensumme = 0;
 31  $zeilennummer = 1;
 32  // Zähle alle Artikel dieses Verkaufsvorgangs auf
 33  if(isset($verkaufsvorgang) && count($verkaufsvorgang["artikel"]) > 0) {
 34    foreach($verkaufsvorgang["artikel"] as $artikel) {
 35      printf('<tr><td class="num">%u</td><td>%s</td><td>%s</td>',
                $zeilennummer++,
                htmlspecialchars($artikel["id"]),
                htmlspecialchars($artikel["titel"]));
 36      print(sprintf('<td class="num">%2.2f €</td><td class="num">%s %%</td>
                        <td class="num">%2.2f €</td>',
                       $artikel["nettopreis"],
                       $artikel["ustsatz"] * 1,
                       $artikel["bruttopreis"]));
 37      if($verkaufsvorgang["abgeschlossen"]) {
 38        printf('<td class="num">%s</td>',
                  $artikel["anzahl"]);
 39      } else {
 40        printf('<td><input type="text" size="4" name="anzahl[%s]" value="%d"
                   class="num"></td>',
                  htmlspecialchars($artikel["id"]),
                  $artikel["anzahl"]);
 41      }
 42      printf('<td class="num">%2.2f €</td></tr>',
                $artikel["anzahl"] * $artikel["bruttopreis"]);
 43      $zwischensumme += $artikel["anzahl"] * $artikel["bruttopreis"];
 44      if($artikel["kautionspflicht"]
            && !isset($vorhandene_kautionsscheine[$artikel["kautionspflicht"]])
            && !$verkaufsvorgang["abgeschlossen"]) {
 45        if(in_array($artikel["kautionspflicht"], $moegliche_kautionsscheine)) {
 46          printf('<tr><td></td><td colspan="6" class="fehler">Für diesen Artikel
                     ist ein Kautionsschein »%s« erforderlich. Bitte lassen Sie sich
                     einen solchen vorlegen oder verkaufen Sie einen neuen.</td>
                     <td></td></tr>',
                    $artikel["kautionspflicht"]);
 47          $fehlende_kautionsscheine[] = $artikel["kautionspflicht"];
 48        }
 49      }
 50      [...]
 51    }
 52  }
 53  // Eingabezeile für zusätzliche Artikel
 54  if(!$verkaufsvorgang["abgeschlossen"]) {
 55    printf('<tr><td></td><td><input type="text" name="artikel" size="20"></td>
               <td colspan="5"><input type="submit" name="aktion" value="%s"></td>
               <td></td></tr>',
              count($verkaufsvorgang["artikel"]) > 0
                ? "Artikel hinzufügen / Stückzahlen ändern"
                : "Artikel hinzufügen");
 56  }
 57  // Zähle alle Kautionsscheine dieses Verkaufsvorgangs auf
 58  [...]
 59  // Eingabezeile für zusätzliche Kautionsscheine
 60  [...]
 61  printf('<tr><td></td><td colspan="6" class="num"><big>Summe</big></td>
             <td class="num"><big>%.2f €</big></td></tr>',
            $zwischensumme);
 62  print("</table>\n</form>");
 63  [...]
 64  // Eingabefeld für Kundenregistrierung
 65  [...]
 66  if($verkaufsvorgang["abgeschlossen"]) {
 67    // Zeige Auswahlmöglichkeit »Weitere Schritte«
 68    [...]
 69  } else {
 70    // Verkaufsvorgang abschließen/löschen
 71    print('<h2>Alles in Ordnung?</h2><form action="./" method="POST">');
 72    if(count($verkaufsvorgang["artikel"])
          + count($verkaufsvorgang["kautionsscheine"])
          > 0) {
 73      print('<p><input type="submit" name="aktion" value="Verkauf abschließen">
                oder aber ');
 74    } else {
 75      print('<p>Wohl noch nicht – es sind ja noch gar keine Artikel eingetragen.
                Gegebenfalls: ');
 76    }
 77    print('<input type="submit" name="aktion" value="Verkaufsvorgang löschen"></p>
              </form>');
 78  }
 79  // Fülle die globale Seitenvorlage aus
 80  print(str_replace(
 81    array("%SV_SEITENTITEL%", "%SV_WEB_PFAD%", "%SV_SEITENINHALT%"),
 82    array(sprintf("Verkaufsvorgang %u", $id), WEB_PFAD, ob_get_clean()),
 83    file_get_contents("seitenvorlage.html")));

Sobald der Benutzer eine Aktion auslöst (d. h. ein HTML-Formular absendet), wird der Steuerungs-Teil der Datei »verkaufsvorgang.php« ausgelöst, der zunächst überprüft, welche Aktion ausgeführt werden soll, dann die entsprechenden Daten ans Modell übergibt und schließlich den Benutzer wieder auf das (aktualisierte) Verkaufsformular leitet:

  1  switch($_POST["aktion"]) {
  2    case "Artikel hinzufügen":
  3    case "Artikel hinzufügen / Stückzahlen ändern":
  4      verkaufsvorgang_aendere_anzahl($id, $_POST["anzahl"]);
  5      if(isset($_POST["artikel"])) {
  6        $artikelnummer = get_magic_quotes_gpc()
             ? stripslashes($_POST["artikel"])
             : $_POST["artikel"];
  7        verkaufsvorgang_neuerposten($id, $artikelnummer);
  8      }
  9      header(sprintf("Location: %s://%s%sintern/verkauf/%u/",
                        WEB_PROTOKOLL, WEB_RECHNERNAME, WEB_PFAD, $id));
 10      exit; break;
 11    case "Kautionsschein hinzufügen":
 12      [...]
 13    case "Kautionsschein löschen":
 14      [...]
 15    case "Kunde eintragen":
 16    case "Kunde ändern/löschen":
 17      [...]
 18    case "Verkauf abschließen":
 19      schliesse_verkaufsvorgang($id, $_SERVER["PHP_AUTH_USER"]);
 20      header(sprintf("Location: %s://%s%sintern/verkauf/%u/",
                        WEB_PROTOKOLL, WEB_RECHNERNAME, WEB_PFAD, $id));
 21      exit; break;
 22    case "Verkaufsvorgang löschen":
 23      [...]
 24    default:
 25      [...]
 26  }

Kautionsscheine

Um den Studenten die Vorbereitung auf mündliche Prüfungsleistungen im Rahmen der Diplomvor- und -hauptprüfung zu erleichtern, gibt das Skriptenreferat nach Prüfungszeiträumen gruppierte Sammlungen von Prüfungsprotokollen heraus. Diese Protokolle werden von den Studenten selbst angefertigt; um sicherzustellen, daß regelmäßig genügend dieser Protokolle eingereicht werden, muß jeder Student zunächst eine Kaution in Höhe von derzeit 30 Euro hinterlegen und erhält dafür einen Kautionsschein, der zum Kauf aller Protokollsammlungen des jeweiligen Prüfungsteils berechtigt. Nach Abschluß der Prüfung soll er selbst entsprechende Protokolle anfertigen und einreichen, um die Kaution zurückzuerhalten.

Zunächst war vorgesehen, die Verwaltung dieser Kautionen vollständig in das System zu integrieren. Hierzu sollte es einen Menüpunkt geben, unter dem Korrektoren eintragen können, welcher Student welche Protokolle eingereicht hat und ob diese (inhaltlich und formell) verwertbar sind. Sobald alle zu einem Kautionsschein gehörenden Protokolle als eingereicht und in Ordnung markiert sind, sollte die Kaution zur Rückzahlung freigegeben werden; für den Fall, daß die Protokolle zwar inhaltlich verwertbar sind, aber ihre Form erheblichen Mehraufwand erfordert, sollte der zurückzuzahlende Betrag entsprechend vermindert werden.

Nachdem dann während der Entwicklung des Systems klar wurde, daß die Diplomstudiengänge und damit die Prüfungsprotokolle in absehbarer Zeit vollständig verschwinden werden, wurde beschlossen, diesen Teil des Systems etwas einfacher zu halten und den Vorgang vom Einreichen der Protokolle bis zur Freigabe der Kaution weiterhin in bereits bestehenden Excel-Tabellen zu verwalten. Es wird also nur erfaßt, welcher Kautionsschein wann verkauft wurde und wann die Kaution ganz oder teilweise wieder erstattet wurde. (Die Erfassung dieser Zeitpunkte ist erforderlich, da verfallene oder nur teilweise erstattete Kautionen letztendlich als steuerpflichtige Umsätze gebucht werden müssen.)

Kautionsscheine, die vor Inbetriebnahme des Systems ausgestellt wurden, stellen einen Sonderfall dar, da sie weder numeriert noch datiert sind. Für diese Kautionsscheine wird die Inbetriebnahme des Systems als Beginn der Verfallsfrist angenommen und lediglich Erstattungsdatum und -betrag erfaßt; sobald der Rest der »alten« Kautionsscheine verfällt, kann diese Sonderfunktionalität aus dem System entfernt werden.

Benutzerdokumentation

Die Benutzeroberfläche des Systems wurde von Anfang an sehr einfach ausgelegt, auch um die Benutzerführung möglichst intuitiv zu gestalten – vom Benutzer werden nur grundlegende Kenntnisse im Umgang mit Browsern und Webseiten erwartet. Für die Details, die sich nur schwierig oder gar nicht selbsterklärend umsetzen ließen, war zunächst vorgesehen, eine kleine Sammlung von Online-Hilfetexten zu verfassen, so daß man mit einem Klick auf ein Fragezeichen an der fraglichen Stelle direkt zur passenden Erläuterung gelangen kann. Der geringe Umfang dieser Hilfetexte ließ es dann aber zu, auf eine Auslagerung zu verzichten und sie stattdessen unmittelbar an den entsprechenden Stellen der Benutzeroberfläche anzuzeigen.

Aktueller Zustand

Der Umfang des Programmcodes beläuft sich insgesamt auf etwa 3400 Zeilen. Das System befindet sich seit der Jahresinventur im April 2008, die für die Eingabe des Skriptenbestands in die Datenbank genutzt wurde, im täglichen Einsatz.