Ankundigung von Quotery: KI-gestutzte Angebots- und Fulfillment-Automatisierung fur B2B

Von Diogo Hudson Dias
A confident sales rep in a daylight warehouse office, a glowing laptop in front of her showing the Quotery app ingesting a PDF quote into a clean line-item list, with neatly stacked pallets in soft focus behind — calm, modern, competent.

Quotery ist live unter https://www.quotery.io. Es ist ein multi-tenant B2B SaaS, das den schlimmsten Teil des Alltags von Distributoren und Dienstleistern — aus einem Lieferanten-PDF oder einer Kundentabelle ein sauberes, bepreistes Angebot zu machen — zu einer Ein-Klick-Operation macht. Und es hort nicht auf: Reservierung, Lieferscheine, Rucksendungen, Wareneingange und ein eingebetteter Assistent, der Fragen zu Ihren eigenen Daten beantwortet. DHD Tech hat Quotery entworfen, gebaut und ausgeliefert. Dieser Beitrag ist der technische Rundgang hinter dem Launch.

Das offentliche Versprechen auf der Startseite lautet "Angebote schneller. Liefern mit Sicherheit." Hinter dieser Zeile stehen drei Zahlen, die das Produktteam offentlich vertritt: 70% Zeitersparnis bei der Angebotserfassung, 99,2% Treffergenauigkeit bei Positionen und drei unterstutzte Sprachen (en-US, pt-BR, es-US). Der Text unten zeigt, wie diese Zahlen verdient werden — und warum die Architektur in zwei Jahren immer noch vernunftig aussehen wird.

Ankundigung von Quotery

Quotery zielt auf Distributoren, Unternehmer und Service-Werkstatten, die schnell arbeiten. Der tagliche Schmerz ist jedem vertraut, der in so einem Betrieb gearbeitet hat. Ein Kunde schickt eine Einkaufsliste als unordentliches PDF, oder ein Lieferant schickt ein XLSX, das dem letzten nicht mehr ahnelt. Ein Vertriebler tippt Zeile fur Zeile in das ERP, das die Firma vor Jahren angeschafft hat, gleicht jede Zeile per Augenmass mit einem internen SKU ab und hofft, dass der Preis aktuell ist. Dann passiert das Fulfillment woanders — auf Papier, in einem anderen Tool oder im Kopf eines Mitarbeiters — und wenn der Bestand sich endlich bewegt, sind drei verschiedene Systeme uneins daruber, was verkauft wurde.

Quotery bundelt das alles in einer einzigen, mandantengetrennten Plattform. Ein eingehendes Dokument landet am KI-Import-Endpoint, das System lost deterministisch alles auf, was es gegen Ihren Katalog kann, lasst den Rest vom LLM entscheiden und legt Sie auf einem Entwurfs-Angebot mit klassifizierten Positionen ab. Wenn der Vertriebler zufrieden ist, reserviert das Schliessen des Angebots Bestand. Ein gebuchter Lieferschein verbraucht ihn. Eine Rucksendung bringt ihn zuruck. Ein Wareneingang fugt hinzu. Jede Bewegung schreibt eine append-only Hauptbuchzeile und ein unveranderliches Audit-Event. Nichts an dieser Schleife braucht ein zweites System.

Die Zahlen auf der offentlichen Seite spiegeln die reale Nutzungsform: Die Erfassung ist der teure Schritt, also kommen die 70% Zeitersparnis hauptsachlich vom KI-Import, der fur Sie tippt. Die 99,2% Trefferquote sind die Summe aus deterministischem Code-Match und dem gebundelten LLM-Pick — Details weiter unten. Und die dreisprachige UI gibt es, weil Quoterys erste Kunden innerhalb desselben Arbeitstages zwischen den USA, Brasilien und der Karibik operieren.

Der Rationale Prozess Dahinter

Das Backend von Quotery erzwingt eine geschichtete Architektur, in der jede Django-App in vier Pakete zerlegt ist: models, utils, services, views. Das ist keine Praferenz; das wird durch Konvention und die Form der Testsuite erzwungen. Jede Schicht hat genau eine Verantwortung, und der Datenfluss ist eine Einbahnstrasse rein und eine Einbahnstrasse raus.

Models

Nur Schema. Felddefinitionen, Meta, __str__. Jedes Geschaftsmodell erbt von core.models.BaseModel, was einen UUIDField-Primarschlussel, created_at, updated_at, ein is_deleted-Soft-Delete-Flag und eine soft_delete()-Methode liefert. Der Auth-User ist die einzige dokumentierte Ausnahme. Hier lebt keine Geschaftslogik, und es gibt keine benutzerdefinierten save()-Methoden, die Seiteneffekte vor dem Rest des Codes verstecken.

Utils

Die einzige Schicht, die das ORM beruhren darf. Wenn Sie .objects, .filter(), .create(), .save() oder .delete() sehen, sind Sie in einem utils/-Modul. Die Benennung ist formelhaft — build_<entity>_queryset, apply_<field>_filter, create_<entity>, update_<entity>, delete_<entity> — damit ein Reviewer den Inhalt erraten kann, bevor er die Datei offnet.

Services

Geschaftslogik und Validierung. Services komponieren Utils; sie rufen das ORM nie selbst auf. Payloads werden gegen ein ALLOWED_FIELDS-Whitelist gefiltert, damit ein Client niemals tenant, is_deleted oder Ahnliches uber einen verirrten JSON-Key einschleusen kann. ValueError signalisiert einen Validierungsfehler; fehlende Datensatze werfen Model.DoesNotExist. Jeder bestandsandernde Service lauft in einem transaction.atomic()-Block und sperrt StockItem-Zeilen mit select_for_update() in deterministischer Reihenfolge nach product-id und location-id, um unter gleichzeitigen Schliessungen deadlock-frei zu bleiben.

Views

Schlanke DRF-GenericViewSets, die drei Dinge tun: authentifizieren, deserialisieren, an einen Service delegieren. Fehler werden zeilenweise auf HTTP-Status abgebildet — ValueError wird 400, DoesNotExist wird 404, PermissionDenied wird 403. Pagination ist die konfigurierte PageNumberPagination von DRF; nichts ist handgerollt.

Die Tests spiegeln die Schichten eins zu eins: test_<entity>_models.py, test_<entity>_utils.py, test_<entity>_services.py, test_<entity>_views.py. Ein Fehlschlag sagt Ihnen sofort, in welcher Schicht der Bug sitzt. Linting ist ruff im api-Container; das Pre-Commit des Hosts pinnt black, isort, flake8 und bandit; Swagger uber drf-spectacular wird nur in local und dev eingebunden, damit Produktion niemals einen Introspection-Endpoint preisgibt.

Der Stack

Die Entscheidungen sind absichtlich langweilig. Langweiliges summiert sich uber die Zeit.

  • Backend: Python 3.12, Django 5, DRF 3.15, PostgreSQL 17, Redis 7, Gunicorn.
  • Frontend: React 19, Vite 7, TypeScript, Tailwind CSS, Framer Motion, react-i18next.
  • Auth: django-allauth (Google OAuth) fur den SPA-Tauschvorgang, djangorestframework-simplejwt wo ein klassischer JWT-Fluss gebraucht wird, Session-Cookies als HttpOnly markiert, damit der SPA sie nicht per JavaScript lesen kann.
  • Admin: django-unfold mit Quotery-Branding; Swagger uber drf-spectacular nur in local und dev montiert.
  • KI: das OpenAI-SDK, gerichtet auf gpt-4.1-mini fur die Import-Orchestrierung und gpt-image-1 fur dokument-nahe Bildgebung.
  • Dokumenten-Handling: WeasyPrint fur PDF-Export, python-magic fur MIME-Erkennung, pypdf und openpyxl fur das Parsen eingehender PDF / XLSX / XLS / CSV.
  • Infrastruktur: Docker Compose fur den kompletten Stack lokal, Render.com fur Cloud-Deploy. Frontend und Landing sind unabhangige Render-Static-Sites; die API ist ein Render-Web-Service.

Die Regel fur jede neue Abhangigkeit ist ein Einparagrafen-ADR unter docs/decisions/. Wenn es keinen Paragrafen wert ist, ist es das Install nicht wert.

Ein Cache, der nicht lugt

Redis sitzt vor jedem teuren Read, und es ist der Teil des Systems, der in multi-tenant Software am ehesten still falsch lauft. Quoterys Regel ist, dass jeder Cache-Schlussel durch einen einzigen Helfer geht — core.cache.make_key — der Schlussel in der Form qf:v1:<gruppe>:<scope>:<params-hash> erzeugt. <scope> ist eine Tenant-UUID, ein user:<user_id>-Segment fur Per-User-Caches wie den aktuellen User-Payload, oder das Wortliteral global fur plattformweite Reads. Scopes zu mischen in einem Aufruf wirft ValueError. Ein nacktes cache.set("dashboard", ...) ist unerreichbar.

Die zweite Regel ist Invalidierung statt TTL. Jede Service-Mutation, die gecachte Daten beruhrt, schliesst ihren atomaren Block mit einem expliziten invalidate_<gruppe>(tenant_id=...). TTLs sind ein Sicherheitsnetz, nicht der Korrektheitsmassstab. Eine statische Regressionswache lauft durch jede @transaction.atomic-Service-Funktion im Repo und lasst die Suite fallen, wenn der Korper weder invalidiert noch sich als cache-safe deklariert. Die Wache deckt auch Management-Kommandos und ModelAdmin-Subklassen ab, da beide die Service-Schicht umgehen. Mit diesem Netz wurden die Server-TTLs flachig auf 24 Stunden angehoben, ausser dem autorisierungssensiblen me-Cache, der bei einer Stunde bleibt. Wenn eine Rollenanderung das Cache-Bust verfehlt, sieht im schlimmsten Fall ein User hochstens eine Stunde alte Berechtigungen — keinen Tag.

Die dritte Regel ist soft-fail. cache_get_or_set umhullt sowohl cache.get als auch cache.set mit try-except und fallt auf den Producer zuruck bei jeder Backend-Exception, wobei ein WARNING an den core.cache-Logger geloggt wird. Ein Redis-Vorfall wird zur Latenzregression, nicht zum Ausfall. Gruppen-TTLs und Invalidierungs-Fan-Out leben in einer Matrix neben dem Cache-Modul, also wird jede neue Mutation, die die Matrix vergisst, im Review erwischt.

KI-Angebotsimport: Drei Aufrufe in einer atomaren Transaktion

Das ist das Feature, das die 70% Zeitersparnis verdient. Ein User ladt ein Dokument hoch — PDF, XLSX, XLS, CSV oder eingefugten Text — und das System liefert ein Entwurfs-Angebot zuruck, jede Zeile klassifiziert und eine lesbare Zusammenfassung. Der gesamte Fluss lauft synchron innerhalb eines einzelnen @transaction.atomic-Blocks, also persistiert nichts, wenn etwas schiefgeht.

Call A: Struktur extrahieren

Normalisierter Dokumententext rein, ein typisierter {groups, ungrouped_items}-Payload raus. Der Aufruf nutzt das OpenAI-SDK mit strikt JSON-schema-Response-Format, damit das Modell keine formverschobene Daten zuruckgeben kann. Wenn Call A leer zuruckkommt, wirft der Orchestrator ValueError("no_items_detected"), und die umhullende atomare Transaktion wandelt das in ein sauberes Rollback der sonst ausgelaufenen leeren Quote-Zeile.

Deterministisches Matching

Bevor das Modell ein einziges Produkt sieht, lauft Quotery exakte, case-sensitive Gleichheit gegen vier Code-Spalten auf Product: sku, import_code, internal_code und export_code. Jede Spalte tragt einen GIN-Trigram-Index. Jeder Treffer wird eine EXACT_MATCH-Zeile und uberspringt das LLM komplett. Daher kommen die Kosten- und Genauigkeitszahlen wirklich — Kataloge mit gut gepflegten Codes losen die meisten Zeilen kostenlos.

Shortlist plus Call B: gebundeltes pick-or-reject

Fur jede Zeile, die der deterministische Schritt verpasst hat, baut Quotery eine Shortlist von bis zu 15 Kandidatenprodukten mit den ersten acht Tokens der Zeilenbeschreibung gegen Name und die vier Code-Spalten. Dann werden alle ungelosten Zeilen dem LLM in einem einzigen Batch-Aufruf geschickt. Das Modell wahlt pro Zeile eine Kandidaten-ID oder eine Ablehnung. Die Entscheidungen zu bundeln tauscht mehrere Round-Trips gegen einen — ein Kosten- und Latenzgewinn, der mit der Dokumentlange wachst.

Jede zuruckgegebene ID wird gegen die Shortlist validiert, bevor sie die Datenbank beruhrt. Wenn das Modell eine ID halluziniert, die nie im Kandidatenset war, oder eine cross-tenant ID, die anders durchgesickert ist, fallt diese Zeile auf NOT_FOUND, statt sich an ein falsches Produkt zu binden. Dieser Schutz zahlt: ein falsch bepreistes Angebot im Massenbetrieb ist teurer als ein Angebot mit einer manuellen Zeile, die dem Vertriebler uberlassen wird.

Call C: das Summary-Banner

Nachdem die Zeilen persistieren, fragt Quotery nach einer ein- bis dreisatzigen Zusammenfassung in der Sprache des Users — Englisch, Portugiesisch oder Spanisch. Die Zusammenfassung ist fluchtig. Sie kommt im HTTP-Response-Envelope zuruck, erscheint einmal in einem Import-Banner auf der Angebots-Detailseite und verschwindet beim Reload. Sie wird nie in die Datenbank geschrieben, also hat das Feature weder Migration noch Aufraumgeschichte.

Match-Kind-Chips und die Atomaritatsgarantie

Jede persistierte Zeile tragt einen import_match_kind: EXACT_MATCH, AI_DECISION, NOT_FOUND oder MANUAL fur handgetippte Zeilen. Der SPA rendert einen Chip neben dem Produktlabel jeder Zeile, damit der Vertriebler auf einen Blick sieht, wie jede Zeile klassifiziert wurde. Nichts am KI-Import reserviert Bestand — importierte Angebote landen im Entwurf, der Vertriebler prueft, und der ubliche Close-Fluss reserviert mit allen unten beschriebenen Parallelitatsgarantien.

Angebots-Lebenszyklus + Bestands-Hauptbuch

Ein Quote lebt auf einer Zustandsmaschine: draftsentclosedpartially_delivereddelivered, mit cancelled-Zweigen aus draft, sent und closed. Die Nummerierung pro Tenant und Jahr nutzt einen luckenlosen Zuteiler — Q-YYYY-NNNN — hinter einem SELECT ... FOR UPDATE auf einer QuoteNumberSequence-Zeile, damit zwei parallele Schliessungen im selben Tenant-Jahr nicht kollidieren. Das Stornieren eines geschlossenen Angebots schreibt RELEASE-Hauptbuchzeilen, die die Reservierung zurucksetzen; das Stornieren eines teilweise oder vollstandig gelieferten Angebots gibt 409 zuruck und verweist auf einen Rucksendeschein.

Der Bestand wird in einem append-only Hauptbuch gefuhrt. Jede Anderung eines StockItem schreibt eine StockMovement-Zeile mit typisiertem Kind — RECEIPT, DELIVERY, ADJUSTMENT, RETURN — einem vorzeichenbehafteten Delta und optionalen FKs auf das Quelldokument. Das Hauptbuch bleibt append-only selbst fur Staff: StockMovement._meta.default_permissions ist leer, und der Admin uberschreibt has_add_permission, has_change_permission und has_delete_permission auf False. Manuelle Korrekturen laufen uber einen dedizierten POST /api/stock/{id}/adjust/-Endpoint, der admin-only ist und nicht-leere Notizen verlangt.

Zwei Invarianten verdienen Erwahnung. Erstens darf on_hand negativ werden — Backorders sind in den Geschaften, die Quotery bedient, real, und sie hinter einem 409 zu verstecken ist schlechter als sie sichtbar zu machen. Zweitens darf reserved on_hand uberschreiten: ein Angebot zu schliessen, wenn das Lager knapp ist, gibt kein 409 zuruck; es gibt eine shortages[]-Liste in der Antwort, damit der Vertriebler handeln kann. Die Verfugbarkeitsprufung passiert bei der Lieferung, wo sie hingehort — in dem Moment, wenn sich der Bestand wirklich bewegt. Multi-Location ist nativ: jedes StockItem ist auf eine Location verschlusselt, Locations sind per Tenant, und die Default-Location wird beim Anlegen eines Tenants auto-geseedet.

Fulfillment lauft auf drei Dokumenten mit gemeinsamer draft-zu-posted-Zustandsmaschine und Nummerierung pro Tenant und Jahr in einer einzigen DocumentNumberSequence-Tabelle verschlusselt auf (tenant, prefix, jahr). Lieferscheine nutzen DN-YYYY-NNNN; das Buchen konsumiert on_hand plus reserved und fuhrt das Eltern-Angebot auf partially_delivered oder delivered uber. Rucksendescheine nutzen RN-YYYY-NNNN; das Buchen addiert nur auf on_hand und lasst das Eltern-Angebot unverandert. Wareneingange nutzen SR-YYYY-NNNN; sie sind Lieferanten-Inbound, haben keine Angebotsverbindung und addieren auf on_hand.

RBAC, Audit, Share-Links und Antworten

Die Autorisierung lauft auf einem benutzerdefinierten Berechtigungskatalog plus einem Gruppe-Berechtigungs-Mapping. Angebote sind nach auth.Group-Mitgliedschaft sichtbarkeitsgesteuert: Commercial-User sehen ihre eigenen Angebote plus von ihrer Gruppe geteilte; admin und manager sehen tenant-weit. Warehouse-User sehen Bestand und Fulfillment-Dokumente, aber nicht die Vertriebsflache. Jede cross-tenant Suche liefert 404 statt 403, damit die API die Existenz von Datensatzen in einem anderen Tenant nie bestatigt.

Jede Geschaftsmutation schreibt ein unveranderliches AuditEvent in derselben Transaktion wie der Schreibvorgang. StockMovement ist die Audit-Spur fur Bestand; AuditEvent ist die Audit-Spur fur alles andere. Zusammen geben sie einem Operator die vollstandige Rekonstruktion davon, wer was wann und gegen welchen Datensatz getan hat.

Der PDF-Export lauft uber WeasyPrint — die Angebots-Detailseite hat einen Ein-Klick-Button "PDF herunterladen", der dasselbe Layout rendert, das der Kunde uber einen offentlichen Share-Link sieht. QuoteShare gibt eine offentliche read-only URL mit rotiertem Token aus, damit ein Vertriebler einen Link schicken kann, der den Tab-Schluss uberlebt, ohne den Rest des Tenants anonymem Verkehr zu offnen. Das Kontaktformular unter /contact schreibt anonyme Einsendungen in ContactMessage, damit das Plattformteam triagieren kann.

Die UI ist vollstandig dreisprachig — en-US, pt-BR und es-US — mit sprach-praefixierten Routen im SPA. Ein geteiltes qf-language-Cookie auf der .quotery.io-Elterndomain tragt die Sprachwahl uber Landing, App und jede zukunftige Subdomain. Und das Produkt wird mit "Antworten, keine Dashboards" ausgeliefert: ein eingebetteter Assistent, der den User in naturlicher Sprache Fragen zu seinen eigenen Angeboten, Kunden und Bestand stellen lasst. Wenn ein Vertriebler wissen will, welche drei Kunden in diesem Quartal am meisten von einem SKU gekauft haben, fragt er; der Assistent baut die Abfrage, die Tenant-Regeln halten die Antwort im Tenant, und die Antwort kommt als Satz und kleine Tabelle zuruck. Kein BI-Tool. Keine Exporte.

Was kommt als Nachstes

  • Den synchronen KI-Import-Pfad hinter einen Background-Worker fur Dokumente mit mehr als ein paar hundert Zeilen schieben, damit der 180-Sekunden-Timeout-Stack zur Nicht-Frage wird.
  • OpenAI-SDK-Fehler auf ubersetzte Toasts im SPA abbilden, statt sie als generische 500er durchschlagen zu lassen.
  • Eine offentliche, stabile REST-Oberflache unter einem versionierten Prefix fur Tenants veroffentlichen, die Quotery in ihre eigenen ERPs integrieren wollen.
  • Streaming der Zwischenschritte des Imports hinzufugen, damit die Verarbeitungsseite echten Fortschritt statt Zeittakt zeigt.
  • Die Verankerung des eingebetteten Assistenten auf Fulfillment-Dokumente ausweiten, nicht nur Angebote und Bestand.

Ausprobieren

Quotery lauft jetzt produktiv unter https://www.quotery.io. Produkttour, Preise und Anmeldung leben auf der Landing. Wenn Ihr Team Stunden an der Angebotserfassung verliert und eine mandantengetrennte Plattform will, die den Rest der Schleife fahrt, sprechen Sie mit uns unter https://www.quotery.io/contact. DHD Tech hat es gebaut; wir halten auch die Roadmap; und wir nehmen eine kleine Zahl fruher Design-Partner auf der Integrations-Stufe an.

Verwandte Artikel

Ready to scale your engineering team?

Tell us about your project and we'll get back to you within 24 hours.

Start a conversation