Annonce de Quotery : automatisation des devis et de la logistique B2B par IA

Par 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 est en ligne a https://www.quotery.io. C'est un SaaS B2B multi-tenant qui prend le pire du quotidien des distributeurs et des societes de services — transformer un PDF fournisseur ou un tableur client en devis propre et chiffre — et en fait une operation en un clic. Et il continue : reservation, bons de livraison, retours, receptions de stock, et un assistant integre qui repond a des questions sur vos propres donnees. DHD Tech a concu, construit et livre Quotery. Ce billet est la visite technique derriere le lancement.

La promesse publique sur la home est "Devisez plus vite. Livrez avec certitude." Derriere cette phrase il y a trois chiffres que l'equipe produit defend publiquement : 70% de temps gagne sur la saisie des devis, 99,2% d'exactitude sur la correspondance des lignes et trois langues prises en charge (en-US, pt-BR, es-US). Le texte ci-dessous explique comment ces chiffres sont gagnes — et pourquoi l'architecture aura encore l'air saine dans deux ans.

Annonce de Quotery

Quotery vise les distributeurs, les entrepreneurs et les ateliers de services qui avancent vite. La douleur quotidienne est familiere a quiconque a travaille dans ce genre de structure. Un client envoie une liste de courses en PDF brouillon, ou un fournisseur envoie un XLSX qui ne ressemble pas au precedent. Un commercial retape ligne par ligne dans l'ERP que la boite a adopte il y a des annees, mariant chaque ligne a un SKU interne a l'oeil et en esperant que le prix soit a jour. Ensuite la logistique se passe ailleurs — sur papier, dans un autre outil, ou dans la tete de quelqu'un — et quand le stock bouge enfin, trois systemes differents ne sont pas d'accord sur ce qui a ete vendu.

Quotery compresse tout cela dans une seule plateforme cloisonnee par tenant. Un document entre par l'endpoint d'import IA, le systeme resout deterministement ce qu'il peut contre votre catalogue, demande a un LLM de decider le reste et vous depose sur un devis brouillon avec chaque ligne classee. Quand le commercial est pret, cloturer le devis reserve le stock. Poster un bon de livraison le consomme. Un bon de retour le rend. Une reception l'incremente. Chaque mouvement ecrit une ligne de grand-livre append-only et un evenement d'audit immuable. Rien dans cette boucle ne requiert un deuxieme systeme.

Les chiffres sur le site refletent la forme reelle d'usage : la saisie est l'etape chere, donc les 70% de temps gagne viennent surtout de l'import IA qui retape a votre place. Les 99,2% d'exactitude sont la somme du match deterministe par code et du pick en lot du LLM — details plus bas. Et l'UI trilingue existe parce que les premiers clients de Quotery operent entre les Etats-Unis, le Bresil et les Caraibes dans la meme journee de travail.

Le Processus Rationnel Derriere

Le backend de Quotery impose une architecture en couches ou chaque app Django est divisee en quatre paquets : models, utils, services, views. Ce n'est pas une preference ; c'est impose par la convention et par la forme de la suite de tests. Chaque couche a exactement une responsabilite, et le flux de donnees est a sens unique en entree et a sens unique en sortie.

Models

Schema seulement. Definitions de champs, Meta, __str__. Tous les modeles metier heritent de core.models.BaseModel, qui fournit une cle primaire UUIDField, created_at, updated_at, un flag is_deleted de soft-delete et une methode soft_delete(). L'utilisateur d'auth est la seule exception documentee. Pas de logique metier ici, pas de save() custom qui cache des effets de bord.

Utils

La seule couche autorisee a toucher l'ORM. Si vous voyez .objects, .filter(), .create(), .save() ou .delete(), vous etes dans un module utils/. Le nommage est formulaique — build_<entite>_queryset, apply_<champ>_filter, create_<entite>, update_<entite>, delete_<entite> — pour qu'un relecteur devine le contenu du fichier avant de l'ouvrir.

Services

Regles metier et validation. Les services composent les utils ; ils n'appellent jamais l'ORM eux-memes. Les payloads passent par un whitelist ALLOWED_FIELDS pour qu'un client ne puisse jamais injecter tenant, is_deleted ou similaire par une cle JSON perdue. ValueError signale une erreur de validation ; les enregistrements absents remontent en Model.DoesNotExist. Chaque service qui touche au stock tourne dans transaction.atomic() et verrouille les lignes StockItem avec select_for_update() dans un ordre deterministe par product-id et location-id, pour rester sans deadlock sous cloture concurrente.

Views

Des GenericViewSet DRF qui font trois choses : authentifier, deserialiser, deleguer a un service. Les erreurs sont mappees aux codes HTTP ligne par ligne — ValueError devient 400, DoesNotExist devient 404, PermissionDenied devient 403. La pagination est la PageNumberPagination configuree de DRF ; rien n'est artisanal.

Les tests reflettent les couches une par une : test_<entite>_models.py, test_<entite>_utils.py, test_<entite>_services.py, test_<entite>_views.py. Un echec vous dit deja dans quelle couche le bug vit. Le lint est ruff dans le container api ; le pre-commit du host epingle black, isort, flake8 et bandit ; le Swagger via drf-spectacular n'est monte qu'en local et dev, donc la prod n'expose jamais d'endpoint d'introspection.

La Stack

Les choix sont volontairement ennuyeux. L'ennuyeux compose dans le temps.

  • 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) pour l'echange cote SPA, djangorestframework-simplejwt quand un flux JWT classique est necessaire, cookies de session marques HttpOnly pour que le SPA ne puisse pas les lire via JavaScript.
  • Admin : django-unfold aux couleurs de Quotery ; Swagger via drf-spectacular monte uniquement en local et en dev.
  • IA : le SDK OpenAI pointe sur gpt-4.1-mini pour l'orchestration d'import et gpt-image-1 pour les visuels documentaires.
  • Documents : WeasyPrint pour l'export PDF, python-magic pour la detection MIME, pypdf et openpyxl pour parser les PDF / XLSX / XLS / CSV entrants.
  • Infrastructure : Docker Compose pour la stack complete en local, Render.com pour le deploiement cloud. Le frontend et la landing sont des sites statiques Render independants ; l'API est un web service Render.

La regle pour toute nouvelle dependance est un ADR d'un paragraphe sous docs/decisions/. Si ca ne vaut pas un paragraphe, ca ne vaut pas l'install.

Un Cache Qui Ne Ment Pas

Redis est devant toutes les lectures couteuses, et c'est la partie du systeme la plus susceptible de deraper silencieusement en multi-tenant. La regle Quotery est que chaque cle de cache passe par un seul helper — core.cache.make_key — qui produit des cles de la forme qf:v1:<groupe>:<scope>:<hash-params>. <scope> est un UUID de tenant, un segment user:<user_id> pour les caches par utilisateur comme le payload de l'utilisateur courant, ou le litteral global pour les lectures plateforme. Melanger des scopes dans un meme appel leve ValueError. Un cache.set("dashboard", ...) nu est inaccessible.

La deuxieme regle est l'invalidation plutot que le TTL. Chaque service qui mute des donnees mises en cache termine son bloc atomique par un invalidate_<groupe>(tenant_id=...) explicite. Les TTL sont un filet de securite, pas le critere de correction. Un garde statique parcourt chaque service @transaction.atomic du repo et fait echouer la suite si le corps n'invalide pas et ne se declare pas cache-safe. Le garde couvre aussi les management commands et les ModelAdmin, puisque les deux contournent la couche service. Avec ce filet, les TTL cote serveur sont montes a 24 heures partout, sauf le cache me sensible a l'autorisation qui reste a une heure. Si un changement de role echoue a invalider le cache, le pire cas est qu'un utilisateur voit d'anciennes permissions pendant une heure au plus — pas une journee.

La troisieme regle est soft-fail. cache_get_or_set enveloppe cache.get et cache.set dans try-except et retombe sur le producteur a la moindre exception backend, en loggant un WARNING sur le logger core.cache. Un incident Redis devient une regression de latence, pas une indisponibilite. Les TTL par groupe et le fan-out d'invalidation vivent dans une matrice a cote du module de cache, donc toute nouvelle mutation qui oublie la matrice est attrapee en revue.

Import IA : Trois Appels dans une Transaction Atomique

C'est la feature qui gagne les 70% de temps economise. L'utilisateur depose un document — PDF, XLSX, XLS, CSV ou texte colle — et le systeme repond avec un devis brouillon, chaque ligne classee, et un resume lisible. Tout le flux tourne de maniere synchrone dans un unique bloc @transaction.atomic, donc si quoi que ce soit echoue, rien ne persiste.

Call A : extraction de structure

Texte normalise en entree, payload type {groups, ungrouped_items} en sortie. L'appel utilise le SDK OpenAI avec un response format JSON-schema strict, donc le modele ne peut pas retourner de donnee a la forme derivee. Quand Call A retourne vide, l'orchestrateur leve ValueError("no_items_detected"), et la transaction atomique autour convertit cela en rollback propre de la ligne Quote vide qui fuirait sinon en base.

Match deterministe

Avant que le modele ne voie un seul produit, Quotery fait une egalite exacte et sensible a la casse contre quatre colonnes de code sur Product : sku, import_code, internal_code et export_code. Chaque colonne porte un index GIN trigramme. Chaque hit devient une ligne EXACT_MATCH et saute totalement le LLM. C'est de la que viennent vraiment les chiffres de cout et d'exactitude — les catalogues avec des codes bien tenus resolvent la majorite des lignes gratuitement.

Shortlist plus Call B : pick-or-reject en lot

Pour chaque ligne que l'etape deterministe a ratee, Quotery construit une shortlist d'au plus 15 produits candidats avec les huit premiers tokens de la description contre le nom et les quatre colonnes de code. Ensuite toutes les lignes non resolues sont envoyees au LLM en un seul appel en lot. Le modele pioche un id de candidat par ligne ou rejette. Regrouper les decisions echange plusieurs allers-retours pour un — gain de cout et de latence qui grandit avec la taille du document.

Chaque id retourne est valide contre la shortlist avant de toucher la base. Si le modele hallucine un id qui n'etait pas dans le set de candidats, ou un id cross-tenant qui aurait fuite, cette ligne retombe en NOT_FOUND plutot que de se lier au mauvais produit. Ce garde-fou compte : un devis mal chiffre a l'echelle coute plus cher qu'un devis avec une ligne manuelle laissee au commercial.

Call C : la banniere de resume

Une fois les lignes persistees, Quotery demande un resume d'une a trois phrases dans la langue de l'utilisateur — anglais, portugais ou espagnol. Le resume est ephemere. Il revient dans l'enveloppe de la reponse HTTP, est affiche une seule fois dans une banniere sur la page de detail, et disparait au rechargement. Il n'est jamais ecrit en base, donc la feature n'a ni migration ni histoire de nettoyage.

Puces de match-kind et garantie d'atomicite

Chaque ligne persistee porte un import_match_kind : EXACT_MATCH, AI_DECISION, NOT_FOUND ou MANUAL pour les lignes saisies a la main. Le SPA affiche une puce a cote de l'etiquette produit pour que le commercial voie d'un coup comment chaque ligne a ete classee. Rien dans l'import IA ne reserve de stock — les devis importes atterrissent en brouillon, le commercial revise, et le flux de cloture habituel reserve avec toutes les garanties de concurrence decrites plus bas.

Cycle de Vie du Devis + Grand-livre de Stock

Un Quote vit sur une machine a etats : draftsentclosedpartially_delivereddelivered, avec des branches cancelled depuis draft, sent et closed. La numerotation par tenant et par annee utilise un allocateur sans trou — Q-YYYY-NNNN — backee par un SELECT ... FOR UPDATE sur une ligne de QuoteNumberSequence, pour que deux clotures concurrentes dans le meme tenant-annee ne se percutent pas. Annuler un devis cloture ecrit des lignes RELEASE de grand-livre qui defont la reservation ; annuler un devis partiellement ou totalement livre renvoie 409 et vous redirige vers un bon de retour.

Le stock est suivi dans un grand-livre append-only. Chaque changement sur un StockItem ecrit une ligne StockMovement avec un kind type — RECEIPT, DELIVERY, ADJUSTMENT, RETURN — un delta signe et des FKs optionnelles vers le document source. Le grand-livre reste append-only meme pour le staff : StockMovement._meta.default_permissions est vide, et l'admin surcharge has_add_permission, has_change_permission et has_delete_permission a False. Les corrections manuelles passent par un endpoint dedie POST /api/stock/{id}/adjust/, reserve aux admins, qui exige des notes non vides.

Deux invariants meritent d'etre soulignes. D'abord, on_hand a le droit d'etre negatif — les backorders existent reellement dans les metiers que Quotery sert, et les cacher derriere un 409 est pire que de les afficher. Ensuite, reserved a le droit de depasser on_hand : cloturer un devis quand l'entrepot est court ne renvoie pas 409 ; il renvoie une liste shortages[] dans la reponse pour que le commercial agisse. La verification de disponibilite se passe a la livraison, la ou elle doit se passer — au moment ou le stock bouge vraiment. Le multi-site est natif : chaque StockItem est clef par Location, les locations sont par tenant, et la location par defaut est auto-cree quand un Tenant nait.

La logistique roule sur trois documents avec une machine a etats partagee draft-a-poste et une numerotation par tenant et par annee sur une seule table DocumentNumberSequence clef par (tenant, prefixe, annee). Les bons de livraison utilisent DN-YYYY-NNNN ; poster consomme on_hand plus reserved et fait passer le devis parent en partially_delivered ou delivered. Les bons de retour utilisent RN-YYYY-NNNN ; poster ajoute a on_hand uniquement et laisse le devis parent tranquille. Les receptions utilisent SR-YYYY-NNNN ; elles sont entrantes fournisseur, n'ont pas de lien devis, et ajoutent a on_hand.

RBAC, Audit, Liens de Partage et Reponses

L'autorisation tourne sur un catalogue de permissions custom plus un mapping groupe-permission. Les devis ont leur visibilite filtree par appartenance a auth.Group : les commerciaux voient leurs propres devis plus ceux partages par leur groupe ; admin et manager voient tout le tenant. Les utilisateurs d'entrepot voient le stock et les documents logistiques, mais pas la surface de vente. Toute requete cross-tenant renvoie 404 plutot que 403, pour que l'API ne confirme jamais l'existence d'enregistrements dans un autre tenant.

Chaque mutation metier ecrit un AuditEvent immuable dans la meme transaction que l'ecriture. StockMovement est la piste d'audit du stock ; AuditEvent est la piste d'audit de tout le reste. Ensemble ils donnent a un operateur la reconstruction complete de qui a fait quoi, quand et contre quel enregistrement.

L'export PDF passe par WeasyPrint — la page de detail du devis a un bouton "telecharger PDF" qui rend la meme mise en page que le client voit sur un lien public. QuoteShare emet une URL publique en lecture seule avec un jeton rotatif, donc un commercial peut envoyer un lien qui survit a la fermeture de l'onglet sans ouvrir le reste du tenant au trafic anonyme. Le formulaire de contact sur /contact ecrit les soumissions anonymes dans ContactMessage pour le triage par l'equipe plateforme.

L'UI est integralement trilingue — en-US, pt-BR et es-US — avec des routes prefixees par langue dans le SPA. Un cookie partage qf-language sur le domaine parent .quotery.io transporte le choix de l'utilisateur entre la landing, l'app et tout sous-domaine futur. Et le produit sort deja avec "Des reponses, pas des tableaux de bord" : un assistant integre qui laisse l'utilisateur poser des questions en langage naturel sur ses propres devis, clients et stock. Quand un commercial veut savoir quels trois clients ont achete le plus d'un SKU donne ce trimestre, il demande ; l'assistant compose la requete, les regles de tenant maintiennent la reponse dans le tenant, et la reponse revient comme une phrase et un petit tableau. Pas d'outil BI. Pas d'export.

La Suite

  • Mettre le chemin d'import IA synchrone derriere un worker en arriere-plan pour les documents de plus de quelques centaines de lignes, pour que la pile de timeout de 180 secondes devienne un non-probleme.
  • Mapper les erreurs du SDK OpenAI sur des toasts traduits dans le SPA au lieu de les laisser remonter en 500 generique.
  • Publier une surface REST publique et stable sous un prefixe versionne pour les tenants qui veulent integrer Quotery dans leur propre ERP.
  • Ajouter le streaming des etapes intermediaires de l'import pour que la page de traitement reflete un vrai progres plutot qu'un minuteur.
  • Elargir l'ancrage de l'assistant integre pour couvrir les documents logistiques, pas seulement les devis et le stock.

Essayez

Quotery est en production maintenant a https://www.quotery.io. La visite produit, les prix et l'inscription vivent sur la landing. Si votre equipe saigne des heures sur la saisie de devis et veut une plateforme cloisonnee par tenant qui fait tourner le reste de la boucle, parlez-nous a https://www.quotery.io/contact. DHD Tech l'a construit ; nous tenons aussi le roadmap ; et nous acceptons un petit nombre de partenaires de design precoces pour les integrations.

Articles connexes

Ready to scale your engineering team?

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

Start a conversation