# Eine Lager-App entwickeln

> Drei Leute, rund 2.000 Autoteile und eine Bestandstabelle, die nicht mehr mitkam. Sie haben auf Reddit gefragt, was sie stattdessen nehmen sollen, und genau aufgeschrieben, was sie brauchen. Wir haben es an einem Nachmittag entwickelt, und das meiste, was dieser Nachmittag gefunden hat, war ein Fehler in unserer Plattform, nicht in der App.

Source: https://www.cordango.com/de/blog/building-a-warehousing-app/
Language: de

Am Montag, dem 5. Oktober, haben wir einen Nachmittag lang eine Lager-App entwickelt, für eine Firma, die wir nur aus einem Reddit-Post kannten.

Drei Leute. Sie verkaufen OEM-Autoteile, ungefähr 2.000 Artikel, aus einem Lager, in dem eine Person arbeitet. Der Bestand stand in einer Tabelle, und sie haben gefragt, was sie stattdessen nehmen sollen. Einen Nachmittag wert war der Post, weil sie aufgeschrieben hatten, was sie brauchen: Scannen mit den Barcodes, die die Hersteller sowieso auf die Kartons drucken, feste Lagerplätze, Mindestbestand, ein Blick darauf, was schon bestellt ist, und eine Möglichkeit, eine Lieferung gegen die Bestellung zu prüfen, zu der sie gehört. Und über allem: Es musste so einfach sein, dass die eine Person im Lager damit klarkommt.

Diese letzte Zeile mochten wir mehr als den ganzen Rest der Liste. Die meiste Lagersoftware wird für die Person entwickelt, die den Vertrag unterschreibt, und wer an einem Dienstagmorgen 40 Bremsscheiben zählen muss, bekommt den Bildschirm, der übrig geblieben ist.

Also haben wir sie entwickelt, mit Beispieldaten in die [App-Bibliothek](/de/apps/warehouse/) gestellt und den Quelltext in unserem [Beispiel-Repository](https://github.com/cordango/examples/tree/main/warehouse) veröffentlicht. So lief es, auch mit dem, was die App an unserer eigenen Plattform gefunden hat.

## Am Anfang standen Fragen

Geschrieben hat die App ein Coding-Agent, in einem Ordner, und die Kommandozeile `cordango` hat geprüft. Keine Zeichenfläche, kein Drag-and-drop. Im Ordner liegt eine Anweisungsdatei, die der Agent vor allem anderen liest, und ihre erste Überschrift lautet "Start with questions, not with an entity". Erst fragen, dann modellieren.

Am wichtigsten ist diese Regel, wenn das Briefing vollständig aussieht. Eine Liste wie ihre lädt geradezu dazu ein, sofort Tabellen hinzuschreiben. Entschieden haben die App aber Fragen zur Arbeit. Wer darf ändern, was ein Teil kostet? Wer darf den Bestand von Hand korrigieren? Was passiert, wenn 38 Bremsscheiben ankommen und 40 bestellt waren? Bleibt die Bestellung für die anderen zwei offen?

Die letzte Frage mussten wir selbst entscheiden, und wir haben entschieden: Sie bleibt offen. **Rest comes later** bucht, was angekommen ist, und lässt den Rest als bestellt stehen. **Complete with discrepancies** schließt die Bestellung so, wie sie ist. In beiden Fällen wird die bestellte Menge nie an das angepasst, was tatsächlich kam.

Dann die Schleife:

```
cordango check --app warehouse
cordango check --app warehouse --target platform
```

Der erste Befehl sagt, ob die Definition gültig ist. Der zweite sagt, ob die Plattform sie ausführen kann. Der Agent hat sie nach jeder Änderung laufen lassen, die Beschwerde gelesen, die genannte Zeile repariert und sie wieder laufen lassen.

Herausgekommen ist ein Ordner mit rund 3.800 Zeilen YAML: neun Entitäten, acht Seiten, drei Rollen, zwei Bestell-Lebenszyklen, 24 Befehle und sieben Automationen. (Die Zeilen haben wir hinterher gezählt, weil irgendwer immer fragt.) Das klingt nach viel. Es geht aber Zeile für Zeile nur um ein Lager.

Lieferanten und Kunden stehen nicht in diesem Ordner. Es sind die Firmen, die schon in den Organisationen des Arbeitsbereichs liegen, dieselben Datensätze, die auch ein CRM benutzt. Jede Person ist ein Benutzer der Plattform. Anmeldung, Rollen, Änderungsverlauf, Benachrichtigungen und der Zeitplan, der morgens den Bestand prüft, gehören der Plattform. Die App musste nur das Lager beschreiben.

## Der Bestand wird gebucht, nie getippt

An dieser Entscheidung hängt die ganze App, und sie ist die, die wir am härtesten verteidigen würden.

Niemand tippt den Bestand ein. Jeder Eingang, jeder Versand, jede Zählung, jede Rücksendung und jede Umlagerung ist eine Lagerbewegung, und der Bestand eines Teils ist die Summe seiner Bewegungen. Eine Bewegung wird nie bearbeitet. Ein Fehler wird mit einer weiteren Bewegung korrigiert, deshalb erklärt der Verlauf die Zahl immer und sagt, wer was gemacht hat.

In der Definition sieht das so aus:

```yaml
on_hand:
  computed:
    rollup:
      entity: stock_movement
      via: item
      op: sum
      field: quantity
```

Reserviert und bestellt funktionieren genauso, direkt aus den offenen Aufträgen gelesen. Ein Kundenauftrag hält Bestand fest, sobald er freigegeben ist, bis er versendet oder storniert wird. Eine Bestellung beim Lieferanten zählt als bestellt, bis sie abgeschlossen ist. Alles andere ist Rechnen, das jeder nachprüfen kann. Verfügbar ist Bestand minus reserviert, und die empfohlene Nachbestellung ist `max(0, safety_stock - available - on_order)`, aufgerundet auf die übliche Bestellmenge des Teils.

Hier haben wir auch dem Briefing widersprochen, mit dem wir gearbeitet haben. Es nannte *Reservation* und *ReservationRelease* als Bewegungsarten. Aufschreiben kann man das so, entwickeln sollte man es nicht. Eine Reservierungszeile widerspricht ihrem Auftrag, sobald jemand eine Position ändert, und jeder Weg aus einer Reservierung braucht eine eigene Freigabezeile. Leitet man sie aus dem Zustand des Auftrags ab, kann nichts auseinanderlaufen.

Die Spalte, die jede Bestandstabelle hat, haben wir auch weggelassen. Der laufende Saldo sieht harmlos aus, bis jemand eine Bewegung auf gestern bucht und jede spätere Zeile neu gerechnet werden muss, und zwei Bewegungen in derselben Sekunde haben überhaupt keine definierte Reihenfolge. Eine Inventur hält trotzdem beide Zahlen fest, was das System sagte und was im Regal lag ("12, gezählt 11"), weil das Fakten über genau diesen Moment sind.

Ein Bestand, in den jemand etwas eintippen kann, ist bis Freitag falsch. Da sind wir uns ziemlich sicher.

## Drei Minuten mit den Beispieldaten

Die Version aus der Bibliothek bringt rund 50 Teile mit, 20 Fächer, fünf Lieferanten, fünf Kunden, Bestellungen in jedem Zustand und zwei Monate Lagerverlauf. Drei Dinge lohnen sich zum Ausprobieren. Das Scanfeld nimmt einen USB- oder Bluetooth-Scanner, und wenn du keinen hast, tippst du den Code und drückst Enter. Die App selbst ist auf Englisch, deshalb stehen die Knöpfe hier so, wie du sie auf dem Bildschirm siehst.

### Eine Lieferung, die nicht der Bestellung entspricht

Öffne **Receive** und wähle PO-2026-1042 von Nordteile. Erwartet werden 25 Ölfilter, 40 Bremsscheiben und 10 Luftfilter. Scanne den Ölfilter und drück **All arrived**. Scanne die Bremsscheibe, drück **Count** und gib 38 ein. Scanne den Luftfilter, drück wieder **Count** und gib 12 ein.

<figure class="postshot">
  <img src="/assets/templates/app-warehouse-receive.png" alt="Lager auf Cordango: Wareneingang für eine teilweise gelieferte Bestellung, mit dem gescannten Barcode, der passenden Bestellposition und den erwarteten, erhaltenen und noch offenen Mengen" loading="lazy" />
  <figcaption>Wareneingang bei einer Lieferung, die schon zum Teil da ist</figcaption>
</figure>

Die Positionen zeigen jetzt *Complete*, *Short -2* und *Over +2*.

Erwartet waren fünfundsiebzig, angekommen sind fünfundsiebzig. Eine Tabelle würde die Summen vergleichen, feststellen, dass sie stimmen, und alle gingen zufrieden nach Hause, mit zwei falschen Positionen. Deshalb wird pro Position verglichen. **Complete with discrepancies** schließt die Bestellung, Fehl- und Mehrmenge bleiben im Datensatz stehen, und wer bestellt hat, bekommt Bescheid.

### Einen Auftrag kommissionieren

Öffne **Ship**, wähle SO-2026-0834 für ACME Auto und drück **Start picking**. Der Bildschirm zeigt ein Teil nach dem anderen, in der Reihenfolge, in der du durchs Lager gehst, mit Fach und Menge in großer Schrift. Ölfilter, 4, B-01-04. Drück **Picked**, und es geht weiter zur Bremsscheibe, 2, A-03-02.

<figure class="postshot">
  <img src="/assets/templates/app-warehouse-pick.png" alt="Lager auf Cordango: Kommissionieren eines Kundenauftrags, mit dem nächsten Teil, seinem Fach und der Menge in großer Schrift, darunter die Auftragspositionen" loading="lazy" />
  <figcaption>Versand, mitten in einem anderen Auftrag: das nächste Teil, sein Fach und wie viele</figcaption>
</figure>

Um diesen Bildschirm ging es eigentlich bei "einfach genug für eine Person". Ist die letzte Position erledigt, steht am Auftrag *Picking complete, 18 of 18*, und **Ship** bucht den Bestand aus.

### Nachbestellen

Öffne **Replenishment**. Der Bremsbelagsatz BP-5512 hat 7 verfügbar bei einem Mindestbestand von 20, und nichts ist bestellt, also schlägt die App 13 vor. Öffne den Entwurf PO-2026-1045 für Rheinland Autoteile und drück **Add what this supplier needs**. Dann landet jedes Rheinland-Teil von der Nachbestellliste mit der vorgeschlagenen Menge auf der Bestellung.

Wer für das Teil einkauft, erfährt davon, ohne etwas zu öffnen. An jedem Werktagmorgen läuft eine Prüfung und schickt eine Meldung pro Engpass, danach ist Ruhe, bis das Teil nicht mehr nachbestellt werden muss. Eine Meldung, nicht drei Wochen lang jeden Tag eine.

<figure class="postshot">
  <img src="/assets/templates/app-warehouse-dashboard.png" alt="Lager auf Cordango: das Dashboard mit Teilen, Bestand, knappen und fehlenden Teilen, erwarteten Lieferungen und offenen Versänden, den Teilen, die Aufmerksamkeit brauchen, und den letzten Lagerbewegungen" loading="lazy" />
  <figcaption>Das Dashboard: was knapp ist, was fehlt und was ankommt</figcaption>
</figure>

## Was das Lager in unserer Plattform gefunden hat

Wir dachten, die App sei die Arbeit. War sie nicht, und die meisten Überraschungen an diesem Montag lagen bei uns. Behoben haben wir sie in der Plattform, statt die App um sie herumzubiegen.

**Bestellt und reserviert standen bei jedem Teil auf null.** Das YAML war richtig. Unsere Plattform las jeden Wert, der eine Referenz entfernt lag, als Zahl, weil sie nicht sehen konnte, was für ein Feld am anderen Ende sitzt. Ein Ausdruck wie `if(order.is_open, outstanding, 0)` bestand also jede Prüfung und kam dann leer heraus. Das Lager so umzuschreiben, dass es den Fehler umgeht, wäre schneller gewesen, und der Fehler hätte auf die nächste App gewartet. Die Plattform liest jetzt die echte Art des Feldes, auf das sie zeigt.

**Das zweite Teil ohne Barcode wurde abgelehnt.** Barcodes sind eindeutig, weil sich nie zwei Teile einen teilen dürfen. Viele Teile haben aber gar keinen Barcode, und für einen eindeutigen Index ist ein leerer Text ein Wert wie jeder andere. Also war das zweite Teil ohne Barcode ein Duplikat des ersten. Ein leeres eindeutiges Feld wird jetzt als gar kein Wert gespeichert.

**Es gab kein Feld, in das ein Scanner tippen kann.** Ein USB-Scanner gibt sich als Tastatur aus. Er tippt den Code und drückt Enter. Unser normales Suchfeld hat bei jedem Tastendruck gesucht, bei einem 13-stelligen Barcode also dreizehnmal die Liste neu geladen. Das Scanfeld reagiert nur auf Enter, leert sich nach jedem Scan und holt sich den Fokus zurück, damit der nächste Scan an der richtigen Stelle landet.

**Es gab keinen Weg aus einer Tabelle hinein.** Für eine App, deren ganzer Daseinsgrund eine Tabelle ist, war das etwas peinlich.

Der Import wurde noch am selben Tag entwickelt. Er sitzt neben **New part**: Spalten zuordnen, jede Zeile prüfen, die fertigen importieren. Jede Zeile geht denselben Weg wie das Formular, unter der Rolle der Person, die importiert. Wer im Lager arbeitet, kann also keine Teile importieren, weil diese Rolle keine Teile anlegen darf. Eine Zelle, die zwei Dinge bedeuten kann, wird abgelehnt, mit einem Satz, der sagt, wie man sie schreibt. `1,234` ist in einer englischen Tabelle über tausend und in einer deutschen knapp über eins. `03/04/2026` kann März sein oder April. Die Beispieltabelle hält absichtlich bei drei Zeilen an: ein Fach B14, das es nicht gibt, ein OF-2210, das schon ein Teil ist, und eine Artikelnummer, die zweimal vorkommt.

## Am Sonntag wurde daraus eine App, die dir gehört

Das Lager war als Beispiel für die Plattform gedacht, und das haben wir damals auch so gesagt. Es ging darum, wie wenig Lagersoftware nötig ist, wenn die Plattform den Rest erledigt.

Am Sonntag, dem 11. Oktober, haben wir trotzdem den Standalone-Generator darauf angesetzt, und er hat sich geweigert. Der Open-Source-Compiler kannte einige Dinge noch nicht, die die Plattform konnte: Namen auf geteilten Seiten, Filter, die durch eine Referenz greifen wie `item.barcode`, Summen unter Bestellpositionen. Die haben wir in der [Open-Source-Toolchain](/open-source/) nachgezogen.

Jetzt macht `cordango build` aus demselben Ordner eine ganz normale Anwendung mit ASP.NET Core, EF Core und PostgreSQL und einem Vue-3-Frontend. `docker compose up --build` startet sie, und der erste Bildschirm fragt nach dem Administrator. Sie bringt eigene Personen und Organisationen mit, eine API, die du im Browser lesen und aufrufen kannst, und einen MCP-Endpunkt, alles hinter denselben Rollen.

Eine Sache haben wir dafür aufgegeben. Die Detailseiten hatten einen Abschnitt *Changes*, der zeigte, wer welches Feld geändert hat. Der Änderungsverlauf gehört zur Plattform und existiert in der Standalone-Laufzeit nicht, also ist der Abschnitt weg, auf beiden. Der Lagerverlauf eines Teils listet weiterhin jede Bewegung, und das ist bei einem Lager der Verlauf, den sich die Leute wirklich ansehen. Wir halten den Tausch für richtig. Ganz sicher sind wir nicht.

## Wo es jetzt steht

Die Firma, die gefragt hat, probiert es gerade aus. Wie das läuft, erzählen wir erst, wenn es etwas zu erzählen gibt, und ein Lager braucht ein paar Wochen echte Lieferungen, bevor das, was es sagt, viel bedeutet.

Wenn dein Bestand auch in einer Tabelle steht: Die [Lager-App](/de/apps/warehouse/) lässt sich mit den Beispieldaten von oben aus der App-Bibliothek installieren, und die drei Schritte dauern ungefähr drei Minuten. Dann importier deine eigene Teileliste und schau, welche Zeilen sie ablehnt. Diese Liste ist meistens spannender als der Bestand. Noch nicht auf Cordango? [Sichere dir den Frühzugang](/de/start/). Und wenn du lieber YAML liest als klickst, liegt der Quelltext auf [GitHub](https://github.com/cordango/examples/tree/main/warehouse).

BP-5512 steht in den Beispieldaten immer noch bei 7. Irgendwer sollte diese Bremsbeläge bestellen.

