From 9f2edd27aa3900dd31125e38dcc86c968dc9bb1c Mon Sep 17 00:00:00 2001 From: Antoine Poindron <80094837+AntoinePoindron@users.noreply.github.com> Date: Sun, 10 May 2026 17:35:51 +0200 Subject: [PATCH 1/7] chore(company): remove OETH eligibility and annual budget fields (#33) --- .specify/specs/v1/checklist.md | 12 - .specify/specs/v1/plan.md | 318 ------------------ .specify/specs/v1/spec.md | 192 ----------- .specify/specs/v1/tasks.md | 78 ----- .specify/specs/v2-money-correctness/spec.md | 79 ----- ...1d4f7e9a3c2_drop_oeth_and_budget_annual.py | 35 ++ .../c9e8d7a4f1b2_drop_valorisable_agefiph.py | 37 ++ app.py | 4 +- blueprints/caterer/requests.py | 2 - blueprints/client/profile.py | 2 - forms/client.py | 2 - models.py | 4 - scripts/demo_caterer_statuses.py | 3 - seed_data.py | 6 - services/quotes.py | 6 +- services/stripe_service.py | 1 - templates/admin/companies/detail.html | 7 - templates/admin/companies/list.html | 8 - templates/admin/orders/detail.html | 4 - templates/admin/qualification/detail.html | 3 - templates/caterer/orders/detail.html | 4 - templates/client/orders/detail.html | 6 - templates/client/requests/detail.html | 6 - templates/client/settings.html | 19 -- tests/test_notifications.py | 1 - tests/test_quote_calculations.py | 15 - 26 files changed, 74 insertions(+), 780 deletions(-) delete mode 100644 .specify/specs/v1/checklist.md delete mode 100644 .specify/specs/v1/plan.md delete mode 100644 .specify/specs/v1/spec.md delete mode 100644 .specify/specs/v1/tasks.md delete mode 100644 .specify/specs/v2-money-correctness/spec.md create mode 100644 alembic/versions/b1d4f7e9a3c2_drop_oeth_and_budget_annual.py create mode 100644 alembic/versions/c9e8d7a4f1b2_drop_valorisable_agefiph.py diff --git a/.specify/specs/v1/checklist.md b/.specify/specs/v1/checklist.md deleted file mode 100644 index 566091a..0000000 --- a/.specify/specs/v1/checklist.md +++ /dev/null @@ -1,12 +0,0 @@ -# Quality Checklist - -## Functional -- [ ] (to be defined) - -## Technical -- [ ] Dockerfile builds successfully -- [ ] App starts and responds on configured port - -## Deployment -- [ ] Staging deploy succeeds -- [ ] No hardcoded secrets in code diff --git a/.specify/specs/v1/plan.md b/.specify/specs/v1/plan.md deleted file mode 100644 index c1f7cef..0000000 --- a/.specify/specs/v1/plan.md +++ /dev/null @@ -1,318 +0,0 @@ -# Technical Plan — Les Traiteurs Engagés - -## Architecture - -- **Backend** : Python 3.11+ avec Flask (léger, suffisant pour ce type d'app CRUD+formulaires) -- **ORM** : SQLAlchemy 2.0 (style `select()`, `session.scalars()`) -- **Base** : PostgreSQL (via `DATABASE_URL` ou SQLite pour le dev local) -- **Frontend** : Jinja2 templates + Tailwind CSS 4 (palette warm, Fraunces/Marianne, Lucide icons) + JavaScript vanilla -- **HTTP** : httpx (pour appels externes : Stripe, Nominatim) -- **Paiements** : Stripe Connect V2 (API REST via httpx, pas de SDK Python) -- **Géocodage** : Nominatim (OpenStreetMap, gratuit, sans clé) - -Pas de SPA React — on fait du server-side rendering classique avec des formulaires multi-étapes côté serveur et du JS léger pour l'UX (wizard steps, validation temps réel). - -## Data Model - -### Enums (Python Enum, stockés comme VARCHAR) - -``` -UserRole: client_admin, client_user, caterer, super_admin -MembershipStatus: pending, active, rejected -QuoteRequestStatus: draft, pending_review, approved, sent_to_caterers, completed, cancelled -QuoteRequestCatererStatus: selected, responded, transmitted_to_client, rejected, closed -QuoteStatus: draft, sent, accepted, refused, expired -OrderStatus: confirmed, in_progress, delivered, invoiced, paid, disputed -InvoiceStatus: pending, paid, overdue -MealType: dejeuner, diner, cocktail, petit_dejeuner, autre -StructureType: ESAT, EA, EI, ACI -``` - -### Tables - -**users** -- id (UUID PK), email, password_hash, first_name, last_name, role (UserRole) -- company_id (FK nullable), caterer_id (FK nullable) -- is_active, membership_status, created_at - -**companies** -- id (UUID PK), name, siret (unique), address, city, postal_code -- oeth_eligible (bool), budget_annual (decimal), logo_url -- stripe_customer_id, created_at - -**company_services** -- id (UUID PK), company_id (FK), name, description, budget_annual - -**company_employees** -- id (UUID PK), company_id (FK), first_name, last_name, email, position - -**caterers** -- id (UUID PK), name, siret, structure_type (StructureType) -- address, city, postal_code, latitude, longitude -- description, specialties (JSON array), photos (JSON array) -- capacity_min, capacity_max, delivery_radius_km -- is_validated (bool, default false), commission_rate (decimal, default 0.05) -- service_config (JSONB — clés par meal_type, chaque clé: {enabled, description}) -- dietary flags: vegetarian, vegan, halal, kosher, gluten_free, organic (tous bool) -- logo_url, invoice_prefix -- stripe_account_id, stripe_charges_enabled, stripe_payouts_enabled, stripe_onboarded_at -- created_at - -**quote_requests** -- id (UUID PK), title, client_user_id (FK), company_id (FK), company_service_id (FK nullable) -- event_date, event_start_time, event_end_time -- event_address, event_latitude, event_longitude -- guest_count, budget_global, budget_per_person, budget_flexibility (none/5%/10%) -- service_type, secondary_service_type, meal_type, secondary_meal_type -- is_full_day (bool) -- dietary flags + counts: vegetarian_count, vegan_count, halal_count, kosher_count, gluten_free_count, organic_count -- drinks options: drinks_still_water, drinks_sparkling_water, drinks_soft, drinks_alcohol, drinks_hot, drinks_details -- service options: wants_waitstaff, waitstaff_details, wants_equipment, wants_decoration, wants_setup, setup_time -- description, message_to_caterer -- compare_mode (bool), status (QuoteRequestStatus) -- super_admin_notes, created_at - -**quote_request_caterers** -- id (UUID PK), quote_request_id (FK), caterer_id (FK) -- status (QuoteRequestCatererStatus), responded_at, response_rank, refusal_reason - -**quotes** -- id (UUID PK), quote_request_id (FK), caterer_id (FK) -- reference (unique), total_amount_ht, amount_per_person -- valorisable_agefiph, details (JSONB — array of line items) -- valid_until, notes, status (QuoteStatus), created_at - -**orders** -- id (UUID PK), quote_id (FK unique), client_admin_id (FK) -- status (OrderStatus), delivery_date, delivery_address, notes -- stripe_invoice_id, stripe_hosted_invoice_url, created_at - -**payments** -- id (UUID PK), order_id (FK), caterer_id (FK) -- stripe_checkout_session_id, stripe_payment_intent_id, stripe_invoice_id, stripe_charge_id -- amount_cents, application_fee_cents, net_amount_cents -- currency (default EUR), status, succeeded_at, refunded_at, created_at - -**invoices** -- id (UUID PK), esat_invoice_ref, order_id (FK), caterer_id (FK) -- amount_ht, tva_rate, amount_ttc, valorisable_agefiph, esat_mention -- status (InvoiceStatus), created_at - -**commission_invoices** -- id (UUID PK), invoice_number (int, sequential from 1000) -- order_id (FK), party (client/caterer) -- amount_ht, tva_rate (default 0.20), amount_ttc -- status (InvoiceStatus), created_at - -**notifications** -- id (UUID PK), user_id (FK), type, title, body -- is_read (bool), related_entity_type, related_entity_id, created_at - -**messages** -- id (UUID PK), thread_id, sender_id (FK), recipient_id (FK) -- order_id (FK nullable), quote_request_id (FK nullable) -- body, is_read (bool), created_at - -## API / Pages - -### Auth (public) -- `GET /login` — formulaire login -- `POST /login` — authentification -- `GET /signup` — formulaire inscription (choix client/traiteur) -- `POST /signup/client` — inscription client (avec SIRET) -- `POST /signup/caterer` — inscription traiteur -- `GET /logout` — déconnexion -- `GET /reset-password` — demande de reset - -### Client Dashboard -- `GET /client/dashboard` — tableau de bord client -- `GET /client/requests` — mes demandes de devis -- `GET /client/requests/new` — wizard nouvelle demande (7 étapes via query param ?step=1..7) -- `POST /client/requests/new` — soumission du wizard (sauvegarde à chaque étape) -- `GET /client/requests/` — détail d'une demande + devis reçus -- `POST /client/requests//accept-quote` — accepter un devis -- `POST /client/requests//refuse-quote` — refuser un devis -- `GET /client/orders` — mes commandes -- `GET /client/orders/` — détail commande -- `GET /client/search` — recherche traiteurs -- `GET /client/caterers/` — fiche traiteur -- `GET /client/messages` — messagerie -- `GET /client/team` — gestion équipe (admin only) -- `POST /client/team/approve/` — approuver un membre -- `POST /client/team/reject/` — refuser un membre -- `GET /client/settings` — paramètres entreprise -- `GET /client/profile` — profil utilisateur - -### Caterer Dashboard -- `GET /caterer/dashboard` — tableau de bord traiteur -- `GET /caterer/requests` — demandes reçues -- `GET /caterer/requests/` — détail demande -- `POST /caterer/requests//respond` — marquer comme répondu -- `POST /caterer/requests//refuse` — refuser -- `GET /caterer/quotes/new?request=` — éditeur de devis -- `POST /caterer/quotes` — sauvegarder/envoyer devis -- `GET /caterer/orders` — mes commandes -- `GET /caterer/orders/` — détail commande -- `POST /caterer/orders//status` — mettre à jour le statut -- `GET /caterer/profile` — éditer profil -- `POST /caterer/profile` — sauvegarder profil -- `GET /caterer/stripe/onboarding` — démarrer onboarding Stripe -- `GET /caterer/stripe/return` — retour après onboarding -- `GET /caterer/messages` — messagerie - -### Admin Dashboard -- `GET /admin/dashboard` — KPIs et stats -- `GET /admin/qualification` — demandes à qualifier -- `GET /admin/qualification/` — détail demande -- `POST /admin/qualification//approve` — approuver + matching -- `POST /admin/qualification//reject` — rejeter -- `GET /admin/caterers` — gestion traiteurs -- `POST /admin/caterers//validate` — valider traiteur -- `GET /admin/companies` — gestion entreprises -- `GET /admin/payments` — suivi paiements -- `GET /admin/messages` — messagerie admin - -### API (JSON) -- `POST /api/webhooks/stripe` — webhook Stripe (signature HMAC) -- `GET /api/geocode?q=
` — proxy Nominatim -- `POST /api/messages` — envoyer message (AJAX) -- `GET /api/notifications` — notifications non-lues (AJAX) -- `POST /api/notifications//read` — marquer comme lue - -### Santé -- `GET /health` — healthcheck (pas d'appel externe) - -## Dependencies - -### Python (requirements.txt) -``` -flask>=3.0 -sqlalchemy>=2.0 -alembic -psycopg2-binary -httpx -python-dotenv -werkzeug -gunicorn -stripe -``` - -### Frontend -- Tailwind CSS 4 via CDN (https://cdn.tailwindcss.com) avec configuration custom (palette warm, polices) -- Pas de npm, pas de build JS — JS vanilla inline ou fichiers statiques -- Polices Google Fonts : Fraunces (titres serif) + Marianne (corps sans-serif) -- Lucide icons via CDN (SVG inline) - -### Services externes -- PostgreSQL (local ou managé) -- Stripe Connect (compte plateforme requis, clés via env vars) -- Nominatim (gratuit, rate-limited) - -## LLM Integration - -Pas d'intégration LLM dans le MVP. Potentiel futur : aide à la rédaction de menus, suggestion automatique de tarifs, chatbot support. - -## Deployment - -### Dockerfile -```dockerfile -FROM python:3.11-slim -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . -EXPOSE 8000 -CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "app:create_app()"] -``` - -### docker-compose.yml -```yaml -services: - app: - build: . - ports: - - "${HOST_PORT:-8000}:8000" - environment: - - DATABASE_URL=postgresql://postgres:postgres@db:5432/traiteurs - - SECRET_KEY=${SECRET_KEY:-dev-secret} - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} - depends_on: - - db - db: - image: postgres:16 - environment: - - POSTGRES_DB=traiteurs - - POSTGRES_PASSWORD=postgres - volumes: - - pgdata:/var/lib/postgresql/data - -volumes: - pgdata: -``` - -### Variables d'environnement -| Variable | Obligatoire | Description | -|---|---|---| -| `DATABASE_URL` | oui | URL PostgreSQL | -| `SECRET_KEY` | oui | Clé de session Flask | -| `STRIPE_SECRET_KEY` | oui (prod) | Clé secrète Stripe | -| `STRIPE_PUBLISHABLE_KEY` | oui (prod) | Clé publique Stripe | -| `STRIPE_WEBHOOK_SECRET` | oui (prod) | Secret webhook Stripe | - -### Structure des fichiers -``` -/app/data/projects/bright-brook/ -├── app.py # Factory Flask (create_app) -├── config.py # Lecture env vars -├── models.py # SQLAlchemy models (toutes les tables) -├── database.py # Engine, session, init_db -├── requirements.txt -├── Dockerfile -├── docker-compose.yml -├── static/ -│ ├── css/ -│ │ └── app.css # Styles custom (palette warm, scrollbars, composants) -│ └── js/ -│ ├── wizard.js # Logique wizard multi-étapes -│ ├── quote-editor.js # Éditeur de devis interactif -│ └── messages.js # Messagerie AJAX -├── templates/ -│ ├── base.html # Layout Tailwind (palette warm, sidebar role-aware) -│ ├── auth/ -│ │ ├── login.html -│ │ └── signup.html -│ ├── client/ -│ │ ├── dashboard.html -│ │ ├── requests/ -│ │ ├── orders/ -│ │ ├── search.html -│ │ ├── team.html -│ │ └── ... -│ ├── caterer/ -│ │ ├── dashboard.html -│ │ ├── requests/ -│ │ ├── quote_editor.html -│ │ ├── profile.html -│ │ └── ... -│ └── admin/ -│ ├── dashboard.html -│ ├── qualification.html -│ └── ... -├── blueprints/ -│ ├── auth.py # Routes auth -│ ├── client.py # Routes client -│ ├── caterer.py # Routes caterer -│ ├── admin.py # Routes admin -│ ├── api.py # Routes API (webhooks, AJAX) -│ └── middleware.py # Décorateurs auth/rôle -├── services/ -│ ├── matching.py # Algorithme matching traiteurs (haversine) -│ ├── stripe_service.py # Intégration Stripe Connect -│ ├── geocoding.py # Proxy Nominatim -│ ├── notifications.py # Création notifications -│ └── quotes.py # Logique devis (référence, calculs TVA, règle des 3) -└── alembic/ - └── ... # Migrations -``` diff --git a/.specify/specs/v1/spec.md b/.specify/specs/v1/spec.md deleted file mode 100644 index c475d82..0000000 --- a/.specify/specs/v1/spec.md +++ /dev/null @@ -1,192 +0,0 @@ -# Les Traiteurs Engagés - -Marketplace B2B mettant en relation des entreprises avec des traiteurs inclusifs (ESAT, EA, EI, ACI) — structures employant des personnes en situation de handicap ou en parcours d'insertion. L'app aide les entreprises à remplir leurs obligations OETH/AGEFIPH en commandant auprès de ces structures. - -## User Stories - -### Inscription & Authentification -- En tant que visiteur, je veux créer un compte entreprise (client) ou traiteur pour accéder à la plateforme. -- En tant que client, je veux rejoindre mon entreprise via son SIRET — si elle existe déjà, je demande à rejoindre l'équipe ; sinon, je deviens admin. -- En tant que traiteur, je veux m'inscrire avec mes informations ESAT/EA/EI/ACI et attendre la validation d'un super_admin. -- En tant que client_admin, je veux approuver ou refuser les demandes d'adhésion de mes collaborateurs. - -### Demande de devis (wizard 7 étapes) -- En tant que client, je veux créer une demande de devis en renseignant : type de prestation, détails de l'événement, régimes alimentaires, boissons, services complémentaires, budget, et un message optionnel. -- En tant que client, je veux envoyer ma demande directement à un traiteur spécifique ou en mode « comparer 3 devis ». -- En tant que super_admin, je veux qualifier les demandes en mode comparaison et lancer le matching automatique avec les traiteurs compatibles. - -### Devis & Commandes -- En tant que traiteur, je veux recevoir les demandes qui correspondent à mes capacités (zone géographique, régimes, capacité, type de prestation). -- En tant que traiteur, je veux construire un devis ligne par ligne (prestations, boissons, extras) avec calcul automatique des sous-totaux par taux de TVA. -- En tant que client, je veux comparer les devis reçus et en accepter un (ce qui crée la commande). -- En tant que traiteur, je veux suivre l'avancement de mes commandes (confirmée → livrée → facturée → payée). - -### Paiements (Stripe Connect) -- En tant que traiteur, je veux onboarder mon compte Stripe Connect pour recevoir des paiements. -- En tant que client, je veux payer une commande par carte ou virement SEPA via une facture Stripe. -- En tant que plateforme, je veux prélever une commission de 5% sur chaque transaction. - -### Messagerie -- En tant qu'utilisateur, je veux échanger des messages avec les autres parties (client ↔ traiteur, avec le super_admin). - -### Administration -- En tant que super_admin, je veux valider les traiteurs, gérer les entreprises, voir les statistiques et suivre les paiements. -- En tant que client_admin, je veux gérer mon équipe (employés, services/départements). - -### Profil traiteur -- En tant que traiteur, je veux gérer mon profil public (description, spécialités, photos, rayon de livraison, capacité, régimes alimentaires proposés). - -## Functional Requirements - -### FR1 — Authentification & Rôles -- 4 rôles : `client_admin`, `client_user`, `caterer`, `super_admin` -- Inscription avec email/mot de passe -- Détection SIRET à l'inscription (entreprise existante → adhésion, nouvelle → admin auto) -- Système de membership (pending/active/rejected) pour les client_user -- Middleware de routage par rôle (chaque rôle a son dashboard) - -### FR2 — Gestion des entreprises -- CRUD entreprise (nom, SIRET, adresse, éligibilité OETH, budget annuel, logo) -- Gestion des services/départements au sein de l'entreprise -- Gestion des employés (répertoire) -- Premier utilisateur = client_admin, les suivants rejoignent en pending - -### FR3 — Gestion des traiteurs -- Inscription avec type de structure (ESAT, EA, EI, ACI) -- Profil complet : description, spécialités, photos, capacité min/max, rayon de livraison (km) -- Configuration par type de prestation (service_config JSONB) -- Drapeaux alimentaires (végétarien, vegan, halal, casher, sans gluten, bio) -- Validation par super_admin requise avant visibilité publique - -### FR4 — Wizard de demande de devis (7 étapes) -1. Type de service (petit-déjeuner, pause gourmande, plateaux repas, cocktail) + journée complète -2. Détails événement (date, horaires, adresse géocodée, nombre de convives) -3. Régimes alimentaires (drapeaux + comptages) -4. Boissons (eau, soft, alcool, boissons chaudes) -5. Services complémentaires (service en salle, matériel, décoration, installation) -6. Budget (global ou par personne, sync automatique, flexibilité) -7. Récapitulatif + message optionnel → soumission - -Deux modes : direct (1 traiteur ciblé) et comparaison (matching automatique). - -### FR5 — Matching géographique des traiteurs -- Fonction haversine pour calcul de distance -- Filtrage : service_config actif, capacité min/max, régimes couverts, rayon de livraison -- Tri par proximité puis alphabétique - -### FR6 — Règle des 3 premiers répondants -- En mode comparaison, les traiteurs répondent dans l'ordre -- Les 3 premiers devis sont transmis au client automatiquement -- Au 3e devis transmis, les traiteurs restants sont fermés automatiquement - -### FR7 — Éditeur de devis -- Lignes détaillées (label, quantité, prix unitaire HT, taux TVA) -- 3 sections : prestations, boissons, extras -- Sous-totaux par tranche de TVA (5.5%, 10%, 20%) -- Prix par personne calculé automatiquement -- Référence auto-générée (DEVIS-YYYY-NNN) -- Brouillon ou envoi - -### FR8 — Commandes -- Créée à l'acceptation d'un devis -- Statuts : confirmée → en cours → livrée → facturée → payée → litige -- Suivi côté client et traiteur - -### FR9 — Paiements Stripe Connect -- Onboarding traiteur via Stripe Connect V2 (express) -- Facture Stripe après livraison (ligne par taux TVA + commission plateforme) -- Modes de paiement : carte + virement SEPA -- Webhook Stripe pour traitement asynchrone (checkout, invoice, payment_intent) -- Commission 5% côté traiteur + 5% ajouté à la facture client -- Table payments avec montants en centimes, idempotence - -### FR10 — Facturation -- Factures traiteur (invoices) — numéro fourni par le traiteur -- Factures commission plateforme (commission_invoices) — numéro séquentiel auto -- Montant valorisable AGEFIPH affiché - -### FR11 — Messagerie -- Threads par paire d'utilisateurs -- Lié à une commande ou une demande de devis -- Marquage lu/non-lu - -### FR12 — Notifications -- Notifications internes (type, titre, corps, lu/non-lu) -- Liées à une entité (commande, demande, devis) - -### FR13 — Dashboard admin -- KPIs : nombre de demandes, devis, commandes, CA -- Gestion traiteurs (validation) -- Gestion entreprises -- Suivi paiements -- Qualification des demandes en mode comparaison - -### FR14 — Recherche de traiteurs -- Annuaire des traiteurs validés -- Filtrage par spécialité, localisation, capacité -- Fiche traiteur publique avec envoi de demande directe - -## Non-Functional Requirements - -### NFR1 — Stack technique -- Backend : Python (Flask ou FastAPI) + SQLAlchemy 2.0 + PostgreSQL -- Frontend : HTML/CSS/JS avec templates Jinja2 ou pages statiques -- Design : Tailwind CSS 4 avec palette warm du repo source (cream #FAF7F2, terracotta #C4714A, coral-red #FF5455, olive #6B7C4A, navy #1A3A52), polices Fraunces (titres) + Marianne (corps), icônes Lucide (SVG inline) -- Déploiement : Docker (Dockerfile + docker-compose.yml) -- HTTP : httpx uniquement (pas de requests) - -### NFR2 — Sécurité -- Authentification par sessions (cookies sécurisés) -- Autorisation par rôle sur chaque endpoint -- Pas de SQL dynamique — paramètres nommés uniquement -- Validation SIRET côté serveur -- Webhook Stripe vérifié par signature HMAC - -### NFR3 — Performance -- Pages principales < 2s -- Matching géographique en SQL (haversine) -- Pagination sur les listes - -### NFR4 — Géocodage -- API Nominatim (gratuite) pour convertir adresses → lat/lng -- Pas de clé API requise - -### NFR5 — LLM -- Pas d'utilisation de LLM prévue dans le MVP. Le skill expert_llm peut être ajouté plus tard pour des fonctionnalités comme la suggestion de menus ou l'aide à la rédaction de devis. - -## Acceptance Checklist - -### Inscription -- [ ] Un visiteur peut créer un compte client (avec SIRET) -- [ ] Un visiteur peut créer un compte traiteur -- [ ] La détection SIRET fonctionne (entreprise existante → adhésion) -- [ ] Un client_admin peut approuver/refuser les demandes d'adhésion -- [ ] Un super_admin peut valider un traiteur - -### Demande de devis -- [ ] Le wizard 7 étapes fonctionne de bout en bout -- [ ] Le mode direct envoie à un traiteur spécifique -- [ ] Le mode comparaison passe par la qualification admin -- [ ] Le matching géographique sélectionne les bons traiteurs -- [ ] La règle des 3 premiers répondants ferme les traiteurs restants - -### Devis & Commandes -- [ ] Un traiteur peut construire un devis ligne par ligne -- [ ] Les sous-totaux par TVA sont corrects -- [ ] L'acceptation d'un devis crée une commande -- [ ] Le flux de statuts commande fonctionne - -### Paiements -- [ ] L'onboarding Stripe Connect fonctionne -- [ ] La génération de facture Stripe est correcte (lignes, TVA, commission) -- [ ] Le webhook met à jour les paiements -- [ ] Les montants en centimes sont cohérents - -### Messagerie & Notifications -- [ ] Les messages s'échangent entre utilisateurs -- [ ] Les notifications apparaissent au bon moment - -### Admin -- [ ] Le dashboard affiche les KPIs -- [ ] La qualification des demandes fonctionne -- [ ] La gestion des traiteurs/entreprises est opérationnelle diff --git a/.specify/specs/v1/tasks.md b/.specify/specs/v1/tasks.md deleted file mode 100644 index 7845dc6..0000000 --- a/.specify/specs/v1/tasks.md +++ /dev/null @@ -1,78 +0,0 @@ -# Tasks — Les Traiteurs Engagés - -## Phase 1: Foundation - -- [ ] Task 1: Créer le squelette du projet — `app.py` (factory Flask), `config.py` (env vars), `database.py` (engine SQLAlchemy), `requirements.txt`, `Dockerfile`, `docker-compose.yml` -- [ ] Task 2: Créer `models.py` — toutes les tables SQLAlchemy (users, companies, caterers, quote_requests, quotes, orders, payments, invoices, notifications, messages + enums) [P] -- [ ] Task 3: Créer le template de base Tailwind `templates/base.html` — sidebar role-aware, navigation, footer, messages flash, palette warm (cream/terracotta/navy), polices Fraunces/Marianne, Lucide icons, composants partials (StatusBadge, StructureTypeBadge, ConfirmDialog) [P] -- [ ] Task 4: Créer `blueprints/middleware.py` — décorateurs `login_required`, `role_required(role)`, helper `current_user`, gestion des sessions Flask - -## Phase 2: Auth & Inscription - -- [ ] Task 5: Créer `blueprints/auth.py` — routes login/logout/signup, formulaires d'inscription client (avec SIRET) et traiteur (depends on Tasks 1-4) -- [ ] Task 6: Créer les templates auth — `login.html`, `signup.html` avec choix client/traiteur, formulaire SIRET avec détection entreprise existante [P] -- [ ] Task 7: Logique inscription complète — détection SIRET (entreprise existante → adhésion pending, nouvelle → admin auto), création user + company/caterer, service "Direction" par défaut - -## Phase 3: Dashboards & Navigation - -- [ ] Task 8: Créer `blueprints/client.py` (squelette) — routes dashboard, profil, settings + templates de base -- [ ] Task 9: Créer `blueprints/caterer.py` (squelette) — routes dashboard, profil (édition), templates [P] -- [ ] Task 10: Créer `blueprints/admin.py` (squelette) — routes dashboard (KPIs basiques), gestion traiteurs (validation), gestion entreprises [P] -- [ ] Task 11: Navigation dynamique par rôle — sidebar/header avec liens adaptés au rôle, compteur notifications - -## Phase 4: Gestion Équipe (Client) - -- [ ] Task 12: Gestion équipe client_admin — liste des membres (pending/active/rejected), approbation/refus, gestion des services/départements, répertoire employés - -## Phase 5: Profil Traiteur - -- [ ] Task 13: Édition profil traiteur complet — description, spécialités, photos (upload), capacité, rayon de livraison, drapeaux alimentaires, configuration par type de prestation (service_config) - -## Phase 6: Wizard Demande de Devis - -- [ ] Task 14: Créer `static/js/wizard.js` — navigation multi-étapes côté client (afficher/cacher les sections, validation avant passage à l'étape suivante, progress bar) -- [ ] Task 15: Routes et templates wizard étapes 1-3 — type de service, détails événement (avec géocodage adresse via Nominatim), régimes alimentaires -- [ ] Task 16: Routes et templates wizard étapes 4-7 — boissons, services complémentaires, budget (sync global↔par personne), récapitulatif + soumission -- [ ] Task 17: Créer `services/geocoding.py` — proxy Nominatim pour géocodage adresse → lat/lng [P] -- [ ] Task 18: Mode direct vs comparaison — si traiteur ciblé → status `sent_to_caterers` direct ; si compare_mode → status `pending_review` en attente de qualification admin - -## Phase 7: Matching & Qualification Admin - -- [ ] Task 19: Créer `services/matching.py` — fonction haversine SQL, algorithme de matching (service_config, capacité, régimes, rayon géographique), tri par proximité -- [ ] Task 20: Qualification admin — page listing demandes `pending_review`, détail avec boutons approuver/rejeter, approbation lance le matching + crée les `quote_request_caterers` + notifie - -## Phase 8: Devis - -- [ ] Task 21: Créer `static/js/quote-editor.js` — éditeur de devis interactif (ajout/suppression lignes, calcul automatique sous-totaux par TVA, total HT, prix par personne) -- [ ] Task 22: Créer `services/quotes.py` — génération référence (DEVIS-YYYY-NNN), logique règle des 3 (response_rank, auto-transmission, auto-fermeture des restants) -- [ ] Task 23: Routes traiteur pour devis — voir demande, créer/éditer devis (brouillon ou envoi), template éditeur -- [ ] Task 24: Routes client pour devis — voir les devis reçus sur une demande, comparer, accepter (crée commande) ou refuser (avec raison) - -## Phase 9: Commandes - -- [ ] Task 25: Flux commandes complet — création à l'acceptation du devis, page listing et détail côté client et traiteur, mise à jour statut par le traiteur (confirmé → livré → facturé), page détail avec historique - -## Phase 10: Paiements Stripe - -- [ ] Task 26: Créer `services/stripe_service.py` — client Stripe via httpx (pas de SDK), fonctions : créer compte Connect, lien d'onboarding, créer facture, calculer commission -- [ ] Task 27: Onboarding traiteur Stripe — routes `/caterer/stripe/onboarding` et `/caterer/stripe/return`, suivi KYC -- [ ] Task 28: Génération facture Stripe — après livraison, créer facture avec lignes par TVA + commission plateforme, 30j de paiement, carte + SEPA -- [ ] Task 29: Webhook Stripe — `POST /api/webhooks/stripe` avec vérification HMAC, traitement events (invoice.paid, payment_intent, checkout.session), mise à jour table payments avec idempotence -- [ ] Task 30: Factures commission — génération automatique des `commission_invoices` (numéro séquentiel), affichage admin - -## Phase 11: Messagerie & Notifications - -- [ ] Task 31: Créer `services/notifications.py` — création de notifications aux bons moments (nouvelle demande, nouveau devis, devis accepté, commande livrée, etc.) [P] -- [ ] Task 32: Messagerie — `static/js/messages.js` pour chargement AJAX, routes API pour envoyer/lire messages, templates messagerie par rôle, threads par paire d'utilisateurs -- [ ] Task 33: API notifications — `GET /api/notifications` (non-lues), `POST /api/notifications//read`, affichage dans le header (badge) - -## Phase 12: Recherche Traiteurs - -- [ ] Task 34: Annuaire traiteurs publics — page recherche avec filtres (spécialité, localisation, capacité), fiches traiteurs, bouton "demander un devis" (crée demande en mode direct) - -## Phase 13: Polish & Finitions - -- [ ] Task 35: Responsive et UX — vérifier l'affichage mobile, améliorer les formulaires, ajouter les messages flash de confirmation/erreur partout -- [ ] Task 36: Dashboard admin complet — KPIs (nb demandes/devis/commandes/CA par période), graphiques basiques (barres), gestion paiements -- [ ] Task 37: Seed data — script de génération de données de test (2 entreprises, 3 traiteurs, demandes, devis, commandes à différents statuts) -- [ ] Task 38: Healthcheck et configuration production — route `/health`, configuration gunicorn, documentation déploiement diff --git a/.specify/specs/v2-money-correctness/spec.md b/.specify/specs/v2-money-correctness/spec.md deleted file mode 100644 index f72ced2..0000000 --- a/.specify/specs/v2-money-correctness/spec.md +++ /dev/null @@ -1,79 +0,0 @@ -# Spec — Justesse monétaire et numérotation de facture - -## Objectif - -Éliminer deux bugs latents qui finiront par mordre : - -1. **`float` pour des montants en euros** → arrondis silencieux, écarts d'un centime entre commande / facture / commission. -2. **Numérotation `max(invoice_number) + 1`** → race condition. En France, la numérotation des factures doit être strictement séquentielle, sans trou ni doublon (CGI art. 289 et BOI-TVA-DECLA-30-20-20). Deux livraisons simultanées font sauter cette garantie. - -## Surface - -### Colonnes affectées (toutes en `Numeric(...)` côté DB, donc le schéma reste — c'est le typage Python qui change) - -`models.py` : -- `Company.budget_annual: float | None` → `Decimal | None` -- `CompanyService.annual_budget: float | None` → `Decimal | None` -- `QuoteRequest.budget_global: float | None` → `Decimal | None` -- `QuoteRequest.budget_per_person: float | None` → `Decimal | None` -- `Quote.total_amount_ht: float | None` → `Decimal | None` -- `Quote.amount_per_person: float | None` → `Decimal | None` -- `Quote.valorisable_agefiph: float | None` → `Decimal | None` -- `Caterer.commission_rate: float` → `Decimal` -- `Invoice.amount_ht: float` → `Decimal` -- `Invoice.tva_rate: float` → `Decimal` -- `Invoice.amount_ttc: float` → `Decimal` -- `Invoice.valorisable_agefiph: float | None` → `Decimal | None` -- `CommissionInvoice.amount_ht: float` → `Decimal` -- `CommissionInvoice.tva_rate: float` → `Decimal` -- `CommissionInvoice.amount_ttc: float` → `Decimal` - -Les colonnes `Payment.amount_*_cents: int | None` restent `int` — elles sont déjà en cents (entiers), pas d'enjeu de précision. - -### Calculs affectés (passage à arithmétique Decimal) - -- `services/quotes.py`:`calculate_quote_totals` — somme + multiplication ligne par ligne, calcul TVA, fee plateforme. -- `services/stripe_service.py`:`create_invoice_for_order` — conversion en cents pour Stripe (`int(amount * 100)` → `int(amount * Decimal("100"))`), calcul `avg_tva_rate`. - -### Numérotation de facture - -`CommissionInvoice.invoice_number: int` : -- **Avant** : `max_num = session.scalar(select(func.max(CommissionInvoice.invoice_number))) or 0` puis `max_num + 1, max_num + 2`. -- **Après** : colonne avec `Sequence("commission_invoice_number_seq")`, `unique=True`. Postgres garantit l'unicité et l'incrément monotone même en concurrence. - -## Plan de migration - -### Côté code - -Aucune migration de données nécessaire pour le typage Decimal — `Numeric` SQLAlchemy retourne déjà du `Decimal` à la lecture, le `float` du type-hint n'était qu'un mensonge. Le risque de précision vient des `float(...)` casts dans les calculs ; en les supprimant on récupère la justesse. - -### Côté DB (migration Alembic) - -Une seule migration : -- `CREATE SEQUENCE commission_invoice_number_seq` -- Initialiser à `MAX(invoice_number) + 1` pour ne pas réutiliser un numéro existant -- `ALTER TABLE commission_invoices ALTER COLUMN invoice_number SET DEFAULT nextval('commission_invoice_number_seq')` -- `ADD CONSTRAINT commission_invoices_invoice_number_key UNIQUE (invoice_number)` - -Côté ORM, `CommissionInvoice.invoice_number = mapped_column(Integer, Sequence("commission_invoice_number_seq"), unique=True)`. Le code applicatif **n'assigne plus** `invoice_number=...` lors de la création — Postgres le calcule via `DEFAULT nextval(...)`. - -## Compatibilité descendante - -- Aucune rupture API. -- Templates : `{{ "%.2f" | format(amount) }}` fonctionne identiquement avec `Decimal` ou `float`. Aucun changement. -- JSON serialization (`Quote.details`) : les nombres restent stockés comme `float` dans le JSON blob (c'est du calcul intermédiaire, pas un montant fiscal). À voir plus tard si on veut tout migrer. - -## Hors périmètre - -- Migration de la TVA en `Decimal` également côté JSON `details` — peut être fait plus tard. -- Refactor du calcul TVA pour gérer plusieurs taux par devis sans le défaut silencieux à 10% (smell archi 2.2 partie 1) — préférable dans une PR séparée. -- Ajout d'index sur les colonnes financières fréquemment requêtées — séparé. - -## Critères d'acceptation - -- ✅ Tous les `float(...)` casts dans `services/quotes.py` et `services/stripe_service.py` sont supprimés. -- ✅ `models.py` ne mentionne plus `float` que pour les coordonnées géographiques (`latitude`, `longitude`) et le hint de `event_latitude/longitude`. -- ✅ Le `CommissionInvoice.invoice_number` est généré par Postgres, pas par le code Python. -- ✅ La migration Alembic seed la séquence à la valeur courante + 1 si des données existent déjà. -- ✅ L'app démarre, alembic upgrade head passe, login + dashboard fonctionnent. -- ✅ Une simulation de création de commission invoice depuis deux sessions parallèles produit des numéros distincts (smoke test ad-hoc). diff --git a/alembic/versions/b1d4f7e9a3c2_drop_oeth_and_budget_annual.py b/alembic/versions/b1d4f7e9a3c2_drop_oeth_and_budget_annual.py new file mode 100644 index 0000000..72eb29e --- /dev/null +++ b/alembic/versions/b1d4f7e9a3c2_drop_oeth_and_budget_annual.py @@ -0,0 +1,35 @@ +"""drop oeth_eligible and budget_annual from companies + +Revision ID: b1d4f7e9a3c2 +Revises: e5b1c2a4d8f9 +Create Date: 2026-05-07 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "b1d4f7e9a3c2" +down_revision: Union[str, Sequence[str], None] = "e5b1c2a4d8f9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column("companies", "budget_annual") + op.drop_column("companies", "oeth_eligible") + + +def downgrade() -> None: + op.add_column( + "companies", + sa.Column( + "oeth_eligible", sa.Boolean(), nullable=False, server_default=sa.false() + ), + ) + op.add_column( + "companies", + sa.Column("budget_annual", sa.Numeric(precision=12, scale=2), nullable=True), + ) diff --git a/alembic/versions/c9e8d7a4f1b2_drop_valorisable_agefiph.py b/alembic/versions/c9e8d7a4f1b2_drop_valorisable_agefiph.py new file mode 100644 index 0000000..33694a0 --- /dev/null +++ b/alembic/versions/c9e8d7a4f1b2_drop_valorisable_agefiph.py @@ -0,0 +1,37 @@ +"""drop valorisable_agefiph from quotes and invoices + +Revision ID: c9e8d7a4f1b2 +Revises: b1d4f7e9a3c2 +Create Date: 2026-05-07 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "c9e8d7a4f1b2" +down_revision: Union[str, Sequence[str], None] = "b1d4f7e9a3c2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column("quotes", "valorisable_agefiph") + op.drop_column("invoices", "valorisable_agefiph") + + +def downgrade() -> None: + op.add_column( + "quotes", + sa.Column( + "valorisable_agefiph", sa.Numeric(precision=12, scale=2), nullable=True + ), + ) + op.add_column( + "invoices", + sa.Column( + "valorisable_agefiph", sa.Numeric(precision=12, scale=2), nullable=True + ), + ) diff --git a/app.py b/app.py index e42fe49..23b539b 100644 --- a/app.py +++ b/app.py @@ -359,9 +359,7 @@ def mark_notifications_read_on_entity_view(): # the sweep stays narrow. if user.company_id: touched |= bool( - mark_read_for_entity( - db, user_id, "company", user.company_id - ) + mark_read_for_entity(db, user_id, "company", user.company_id) ) elif endpoint == "client.team": # Pending-membership notifs (related_entity_type="user") diff --git a/blueprints/caterer/requests.py b/blueprints/caterer/requests.py index 1b96533..e02c360 100644 --- a/blueprints/caterer/requests.py +++ b/blueprints/caterer/requests.py @@ -302,7 +302,6 @@ def quote_create(qr_id): reference=reference, total_amount_ht=totals["total_ht"], amount_per_person=totals["amount_per_person"], - valorisable_agefiph=totals["valorisable_agefiph"], notes=form.notes.data or "", valid_until=form.valid_until.data, status=QuoteStatus.draft, @@ -414,7 +413,6 @@ def quote_update(qr_id, q_id): quote.lines = new_lines quote.total_amount_ht = totals["total_ht"] quote.amount_per_person = totals["amount_per_person"] - quote.valorisable_agefiph = totals["valorisable_agefiph"] quote.notes = form.notes.data or "" quote.valid_until = ( form.valid_until.data if form.valid_until.data else quote.valid_until diff --git a/blueprints/client/profile.py b/blueprints/client/profile.py index 2b9590d..dadb2b8 100644 --- a/blueprints/client/profile.py +++ b/blueprints/client/profile.py @@ -62,8 +62,6 @@ def settings(): company.address = (form.address.data or "").strip() or None company.city = (form.city.data or "").strip() or None company.zip_code = (form.zip_code.data or "").strip() or None - company.oeth_eligible = form.oeth_eligible.data - company.budget_annual = form.budget_annual.data db.commit() flash("Parametres mis a jour.", "success") return redirect(url_for("client.settings")) diff --git a/forms/client.py b/forms/client.py index e1d3ed7..a09da5a 100644 --- a/forms/client.py +++ b/forms/client.py @@ -149,8 +149,6 @@ class CompanySettingsForm(FlaskForm): address = StringField(validators=[Optional(), Length(max=500)]) city = StringField(validators=[Optional(), Length(max=255)]) zip_code = StringField(validators=[Optional(), Length(max=10)]) - oeth_eligible = BooleanField() - budget_annual = DecimalField(places=2, validators=[Optional(), NumberRange(min=0)]) # logo file is read via request.files (WTForms FileField is overkill for a single optional logo) diff --git a/models.py b/models.py index c572056..e48a96a 100644 --- a/models.py +++ b/models.py @@ -165,8 +165,6 @@ class Company(Base): address: Mapped[str | None] = mapped_column(String(500)) city: Mapped[str | None] = mapped_column(String(255)) zip_code: Mapped[str | None] = mapped_column(String(10)) - oeth_eligible: Mapped[bool] = mapped_column(Boolean, default=False) - budget_annual: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) logo_url: Mapped[str | None] = mapped_column(String(500)) created_at: Mapped[datetime.datetime] = mapped_column( DateTime, server_default=func.now() @@ -428,7 +426,6 @@ class Quote(Base): reference: Mapped[str] = mapped_column(String(50), unique=True) total_amount_ht: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) amount_per_person: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) - valorisable_agefiph: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) notes: Mapped[str | None] = mapped_column(Text) valid_until: Mapped[datetime.date | None] = mapped_column(Date) status: Mapped[QuoteStatus] = mapped_column(String(20), default=QuoteStatus.draft) @@ -524,7 +521,6 @@ class Invoice(Base): amount_ht: Mapped[Decimal] = mapped_column(Numeric(12, 2)) tva_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4)) amount_ttc: Mapped[Decimal] = mapped_column(Numeric(12, 2)) - valorisable_agefiph: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) esat_mention: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime.datetime] = mapped_column( DateTime, server_default=func.now() diff --git a/scripts/demo_caterer_statuses.py b/scripts/demo_caterer_statuses.py index 20b154f..2b0f968 100644 --- a/scripts/demo_caterer_statuses.py +++ b/scripts/demo_caterer_statuses.py @@ -236,7 +236,6 @@ def main(): reference=f"DEMO-{prefix}-SENT-{ts}", total_amount_ht=Decimal("1800.00"), amount_per_person=Decimal("45.00"), - valorisable_agefiph=Decimal("1800.00"), notes="Cocktail dinatoire avec produits du marche.", valid_until=today + datetime.timedelta(days=30), status=QuoteStatus.sent, @@ -266,7 +265,6 @@ def main(): reference=f"DEMO-{prefix}-REFUSED-{ts}", total_amount_ht=Decimal("420.00"), amount_per_person=Decimal("28.00"), - valorisable_agefiph=Decimal("420.00"), notes="Petit-dejeuner buffet : viennoiseries, fruits, boissons chaudes.", valid_until=today + datetime.timedelta(days=10), status=QuoteStatus.refused, @@ -292,7 +290,6 @@ def main(): reference=f"DEMO-{prefix}-ACCEPTED-{ts}", total_amount_ht=Decimal("1700.00"), amount_per_person=Decimal("56.67"), - valorisable_agefiph=Decimal("1700.00"), notes="Diner gastronomique 3 services, service a l'assiette.", valid_until=today + datetime.timedelta(days=20), status=QuoteStatus.accepted, diff --git a/seed_data.py b/seed_data.py index 387ac2c..4107391 100644 --- a/seed_data.py +++ b/seed_data.py @@ -96,8 +96,6 @@ def seed(): address="15 rue de Rivoli", city="Paris", zip_code="75001", - oeth_eligible=True, - budget_annual=50000, ) techcorp = Company( name="TechCorp France", @@ -105,8 +103,6 @@ def seed(): address="42 avenue Jean Jaures", city="Lyon", zip_code="69007", - oeth_eligible=False, - budget_annual=30000, ) db.add_all([acme, techcorp]) db.flush() @@ -376,7 +372,6 @@ def seed(): reference="DEVIS-ESAT1-2026-001", total_amount_ht=Decimal("1350.00"), amount_per_person=Decimal("45.00"), - valorisable_agefiph=Decimal("1350.00"), notes="Menu compose avec des produits de saison.", valid_until=today + datetime.timedelta(days=30), status=QuoteStatus.sent, @@ -406,7 +401,6 @@ def seed(): reference="DEVIS-EATCO-2026-001", total_amount_ht=Decimal("1100.00"), amount_per_person=Decimal("55.00"), - valorisable_agefiph=Decimal("1100.00"), notes="Menu gastronomique adapte aux regimes specifiques.", valid_until=today - datetime.timedelta(days=5), status=QuoteStatus.accepted, diff --git a/services/quotes.py b/services/quotes.py index 642003c..dfafa43 100644 --- a/services/quotes.py +++ b/services/quotes.py @@ -134,7 +134,7 @@ def calculate_quote_totals(details, guest_count, commission_rate=None): """Compute all totals from line items as Decimals. Callers write the relevant fields onto Quote columns - (`total_amount_ht`, `amount_per_person`, `valorisable_agefiph`). + (`total_amount_ht`, `amount_per_person`). Templates that need richer breakdowns (per-section, per-TVA-rate) call this helper at render time — there is no persisted cache. @@ -188,9 +188,6 @@ def calculate_quote_totals(details, guest_count, commission_rate=None): total_ttc / Decimal(str(guest_count)) if guest_count else Decimal("0") ) - # AGEFIPH: total HT is valorisable for ESAT/EA structures - valorisable_agefiph = total_ht - return { "section_totals": {k: v.quantize(CENT) for k, v in section_totals.items()}, "tva_totals": { @@ -204,5 +201,4 @@ def calculate_quote_totals(details, guest_count, commission_rate=None): "platform_fee_ht": platform_fee_ht.quantize(CENT), "platform_fee_tva": platform_fee_tva.quantize(CENT), "platform_fee_ttc": platform_fee_ttc.quantize(CENT), - "valorisable_agefiph": valorisable_agefiph.quantize(CENT), } diff --git a/services/stripe_service.py b/services/stripe_service.py index d13be67..f62b74d 100644 --- a/services/stripe_service.py +++ b/services/stripe_service.py @@ -290,7 +290,6 @@ def create_invoice_for_order(session, order: Order) -> dict[str, Any]: amount_ht=total_ht, tva_rate=avg_tva_rate, amount_ttc=totals["total_ttc"], - valorisable_agefiph=totals["valorisable_agefiph"], esat_mention=f"Structure {caterer.structure_type}" if caterer.structure_type else None, diff --git a/templates/admin/companies/detail.html b/templates/admin/companies/detail.html index e212a49..a449eed 100644 --- a/templates/admin/companies/detail.html +++ b/templates/admin/companies/detail.html @@ -9,9 +9,6 @@
{{ back_button(label="Retour a la liste") }}

{{ company.name }}

- {% if company.oeth_eligible %} - OETH - {% endif %}
@@ -32,10 +29,6 @@

Informations

Ville

{{ company.city or '-' }}{% if company.zip_code %} ({{ company.zip_code }}){% endif %}

-
-

Budget annuel

-

{% if company.budget_annual %}{{ "%.0f"|format(company.budget_annual) }} EUR{% else %}-{% endif %}

-

Inscrite le

{{ company.created_at.strftime('%d/%m/%Y') }}

diff --git a/templates/admin/companies/list.html b/templates/admin/companies/list.html index 9e8b925..188c9d3 100644 --- a/templates/admin/companies/list.html +++ b/templates/admin/companies/list.html @@ -22,7 +22,6 @@

Entreprises

Nom SIRET Ville - OETH Inscrite le @@ -33,13 +32,6 @@

Entreprises

{{ company.name }} {{ company.siret }} {{ company.city or '-' }} - - {% if company.oeth_eligible %} - OETH - {% else %} - - - {% endif %} - {{ company.created_at.strftime('%d/%m/%Y') }} Montants
Par personne
{{ "%.2f"|format(order.quote.amount_per_person or 0) }} €
-
-
Valorisable AGEFIPH
-
{{ "%.2f"|format(order.quote.valorisable_agefiph or 0) }} €
-
diff --git a/templates/admin/qualification/detail.html b/templates/admin/qualification/detail.html index 3d9669b..b18a7e4 100644 --- a/templates/admin/qualification/detail.html +++ b/templates/admin/qualification/detail.html @@ -21,9 +21,6 @@

Entreprise

{{ qr.company.name }}

SIRET : {{ qr.company.siret }}

{% if qr.company.city %}

{{ qr.company.city }}

{% endif %} - {% if qr.company.oeth_eligible %} - OETH - {% endif %}
diff --git a/templates/caterer/orders/detail.html b/templates/caterer/orders/detail.html index 9e7bac4..d31e08c 100644 --- a/templates/caterer/orders/detail.html +++ b/templates/caterer/orders/detail.html @@ -201,10 +201,6 @@

Montants

Par personne
{{ "%.2f"|format(order.quote.amount_per_person or 0) }} €
-
-
Valorisable AGEFIPH
-
{{ "%.2f"|format(order.quote.valorisable_agefiph or 0) }} €
-
diff --git a/templates/client/orders/detail.html b/templates/client/orders/detail.html index 9976ef9..5a62856 100644 --- a/templates/client/orders/detail.html +++ b/templates/client/orders/detail.html @@ -80,12 +80,6 @@

Devis accepte

{{ "%.2f" | format(order.quote.amount_per_person | float) }} EUR {% endif %} - {% if order.quote.valorisable_agefiph %} -
- Valorisable AGEFIPH - {{ "%.2f" | format(order.quote.valorisable_agefiph | float) }} EUR -
- {% endif %} diff --git a/templates/client/requests/detail.html b/templates/client/requests/detail.html index 17e6965..402bd2d 100644 --- a/templates/client/requests/detail.html +++ b/templates/client/requests/detail.html @@ -288,12 +288,6 @@

Devis reçus

{% if quote.amount_per_person %}{{ "%.2f" | format(quote.amount_per_person | float) }} EUR{% else %}-{% endif %} -
-
AGEFIPH
-
- {% if quote.valorisable_agefiph %}{{ "%.2f" | format(quote.valorisable_agefiph | float) }} EUR{% else %}-{% endif %} -
-
{% if quote.notes %} diff --git a/templates/client/settings.html b/templates/client/settings.html index d1cafcf..e895e7d 100644 --- a/templates/client/settings.html +++ b/templates/client/settings.html @@ -54,25 +54,6 @@

Adresse

- {# ---- Block 3 : Budget et obligation d'emploi ---- #} -
-

Budget et obligation d'emploi

-
-
- - -
- -
-
-
- +
diff --git a/templates/client/requests/detail.html b/templates/client/requests/detail.html index 402bd2d..8cfc7ff 100644 --- a/templates/client/requests/detail.html +++ b/templates/client/requests/detail.html @@ -435,11 +435,22 @@

Aucun devis recu

Aperçu du devis

Réf. {{ quote.reference }}

- +
+ {# Server-rendered PDF — identical content to this preview + (same _pdf_preview.html partial). Goes through the + client.quote_pdf route which company-scopes the + access and reuses services.quote_pdf.render_quote_pdf. #} + + + Télécharger + + +
From d706d3a6ca2a479e31fa7d054f0fb448d0cfa8c2 Mon Sep 17 00:00:00 2001 From: Antoine Poindron <80094837+AntoinePoindron@users.noreply.github.com> Date: Sun, 10 May 2026 17:50:05 +0200 Subject: [PATCH 3/7] fix(client/requests): hide draft quotes from the client-facing detail page (#45) --- blueprints/client/requests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/blueprints/client/requests.py b/blueprints/client/requests.py index 0142af7..2564ba9 100644 --- a/blueprints/client/requests.py +++ b/blueprints/client/requests.py @@ -421,10 +421,17 @@ def request_detail(request_id): .all() ) + # Drafts are caterer-only — they're work-in-progress quotes the + # caterer hasn't sent yet. Surfacing them on the client side + # leaks pricing + caterer identity before the caterer is ready + # to commit. Allow-list rather than `!= draft` so a future + # status doesn't leak by default — same gate as the dashboard + # helpers and the client.quote_pdf route. quotes = ( db.execute( select(Quote) .where(Quote.quote_request_id == request_id) + .where(Quote.status.in_(_QUOTE_RECEIVED_STATUSES)) .order_by(Quote.created_at.asc()) ) .scalars() From 4b0911ed8339eeaf9d6afbbfa281679b144432f3 Mon Sep 17 00:00:00 2001 From: Antoine Poindron Date: Sat, 9 May 2026 14:31:14 +0200 Subject: [PATCH 4/7] feat(orders): add "Devis" button + preview modal to every order detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Voir le devis" preview modal already existed on the request detail pages on both sides. Surface it on the order detail pages too so a client can re-read the quote that's been turned into an order, the caterer can re-open it from the order context, and super_admin gets the same observer-view. Three pages, same modal pattern (mirror of the per-quote modal on client/requests/): a "Devis" ghost-pill button on the page, opens an overlay with the existing _pdf_preview.html partial and a "Télécharger" button that hits the role-appropriate PDF route. Pieces: - new GET /admin/quotes//pdf route (super_admin sees every quote, no scope check). Mirrors caterer.quote_pdf and the client.quote_pdf added in #44; same _MAX_PDF_LINES = 500 cap. - the three order_detail handlers (client / caterer / admin) now compute pdf_preview via services.quotes.build_pdf_preview and pass it + meal_type_labels to the template. - modal HTML inlined into each template (one per role to keep the download URL role-specific). Each modal `with`-binds quote, qr and caterer for the included partial. Stacked on #44. Co-Authored-By: Claude Opus 4.7 (1M context) --- blueprints/admin.py | 67 +++++++++++++++++++++++++++- blueprints/caterer/orders.py | 19 +++++++- blueprints/client/orders.py | 12 +++++ templates/admin/orders/detail.html | 46 ++++++++++++++++++- templates/caterer/orders/detail.html | 50 ++++++++++++++++++++- templates/client/orders/detail.html | 55 +++++++++++++++++++++-- 6 files changed, 241 insertions(+), 8 deletions(-) diff --git a/blueprints/admin.py b/blueprints/admin.py index 8e5cde1..3a12796 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -1,4 +1,5 @@ import datetime +from io import BytesIO from flask import ( Blueprint, @@ -8,15 +9,23 @@ redirect, render_template, request, + send_file, url_for, ) from sqlalchemy import func, select -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload + + +# Mirror of the cap on caterer/client routes — refuses to render a +# quote whose line items list is implausibly long. Stops a malicious +# row from saturating the WeasyPrint worker. +_MAX_PDF_LINES = 500 from blueprints.middleware import login_required, role_required from database import get_db from forms.caterer import AdminMessageForm, RejectionForm from models import ( + MEAL_TYPE_LABELS, Caterer, Company, CompanyEmployee, @@ -624,10 +633,66 @@ def order_detail(order_id): _ = order.quote.quote_request.company _ = order.quote.caterer _ = order.payments + # Powers the "Devis" button + preview modal on this page — + # same pdf_preview dict the request-detail modal feeds on. + from services.quotes import build_pdf_preview + + pdf_preview = ( + build_pdf_preview( + order.quote, order.quote.quote_request, order.quote.caterer + ) + if order.quote.lines + else None + ) return render_template( "admin/orders/detail.html", user=g.current_user, order=order, + pdf_preview=pdf_preview, + meal_type_labels=MEAL_TYPE_LABELS, + ) + + +@admin_bp.route("/quotes//pdf", methods=["GET"]) +@login_required +@role_required("super_admin") +def quote_pdf(q_id): + """Download any quote as a server-rendered PDF (admin observer view). + + Mirrors `caterer.quote_pdf` and `client.quote_pdf` but with no + company- or caterer-scope check — super_admin sees every quote on + the platform. The PDF reuses the same `_pdf_preview.html` partial + as the in-app modals so the file is byte-for-byte aligned with + what either side sees on screen. + """ + # Lazy import — WeasyPrint pulls Cairo/Pango bindings at import + # time. Same rationale as the other quote_pdf routes. + from services.quote_pdf import render_quote_pdf + + db = get_db() + quote = db.scalar( + select(Quote) + .options( + selectinload(Quote.lines), + joinedload(Quote.caterer), + joinedload(Quote.quote_request).options( + joinedload(QuoteRequest.company), + joinedload(QuoteRequest.user), + ), + ) + .where(Quote.id == q_id) + ) + if not quote: + abort(404) + if len(quote.lines) > _MAX_PDF_LINES: + abort(413) + + pdf_bytes = render_quote_pdf(quote, quote.quote_request, quote.caterer) + return send_file( + BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=f"devis-{quote.reference}.pdf", ) diff --git a/blueprints/caterer/orders.py b/blueprints/caterer/orders.py index ff6ef5c..2cd2b5f 100644 --- a/blueprints/caterer/orders.py +++ b/blueprints/caterer/orders.py @@ -5,7 +5,7 @@ from blueprints.scoping import get_caterer_order from database import get_db from extensions import limiter -from models import Order, OrderStatus, Quote +from models import MEAL_TYPE_LABELS, Order, OrderStatus, Quote from services import workflow @@ -75,8 +75,23 @@ def order_detail(order_id): _ = order.quote.quote_request.company _ = order.quote.quote_request.user _ = order.payments + # Powers the "Devis" button + preview modal on this page — + # same pdf_preview dict the request-detail modal feeds on. + from services.quotes import build_pdf_preview + + pdf_preview = ( + build_pdf_preview( + order.quote, order.quote.quote_request, order.quote.caterer + ) + if order.quote.lines + else None + ) return render_template( - "caterer/orders/detail.html", user=g.current_user, order=order + "caterer/orders/detail.html", + user=g.current_user, + order=order, + pdf_preview=pdf_preview, + meal_type_labels=MEAL_TYPE_LABELS, ) @bp.route("/orders//deliver", methods=["POST"]) diff --git a/blueprints/client/orders.py b/blueprints/client/orders.py index e937499..08b281c 100644 --- a/blueprints/client/orders.py +++ b/blueprints/client/orders.py @@ -106,6 +106,16 @@ def order_detail(order_id): db, order=order, viewer=user ) + # Powers the "Devis" button + preview modal on this page — + # same pdf_preview dict the request-detail modal feeds on. + from services.quotes import build_pdf_preview + + pdf_preview = ( + build_pdf_preview(order.quote, order.quote.quote_request, caterer) + if order.quote.lines + else None + ) + return render_template( "client/orders/detail.html", user=user, @@ -114,6 +124,8 @@ def order_detail(order_id): caterer_user=caterer_user, existing_review=existing_review, review_form_visible=review_form_visible, + pdf_preview=pdf_preview, + meal_type_labels=MEAL_TYPE_LABELS, ) @bp.route("/orders//review", methods=["POST"]) diff --git a/templates/admin/orders/detail.html b/templates/admin/orders/detail.html index a0e3d58..8d1f1f5 100644 --- a/templates/admin/orders/detail.html +++ b/templates/admin/orders/detail.html @@ -105,7 +105,16 @@

Livraison

-

Montants

+
+

Montants

+ {% if pdf_preview %} + + {% endif %} +
Total HT
@@ -146,4 +155,39 @@

Paiements

{% endif %}
+ +{# Quote preview modal — admin observer view, no scope needed for the + download (admin.quote_pdf serves any quote). #} +{% if pdf_preview %} + +{% endif %} {% endblock %} diff --git a/templates/caterer/orders/detail.html b/templates/caterer/orders/detail.html index d31e08c..a47f02d 100644 --- a/templates/caterer/orders/detail.html +++ b/templates/caterer/orders/detail.html @@ -108,7 +108,16 @@

Livraison

{% if order.quote.lines %}
-

Detail du devis

+
+

Detail du devis

+ {% if pdf_preview %} + + {% endif %} +
@@ -228,4 +237,43 @@

Montants

+{# ============================================================ + Quote preview modal — opened by the "Devis" button above. + Mirrors the per-quote modal on client/requests/detail.html: + same _pdf_preview.html partial + header with a Télécharger + button pointing at caterer.quote_pdf for this order's quote. + ============================================================ #} +{% if pdf_preview %} + +{% endif %} + {% endblock %} diff --git a/templates/client/orders/detail.html b/templates/client/orders/detail.html index 5a62856..0172aa2 100644 --- a/templates/client/orders/detail.html +++ b/templates/client/orders/detail.html @@ -61,9 +61,18 @@

Progression

duplicate it. Kept the "Devis accepté" card and gave it the full width now that it's the sole occupant. #}
-
- -

Devis accepte

+
+
+ +

Devis accepte

+
+ {% if pdf_preview %} + + {% endif %}
@@ -305,6 +314,46 @@

Laisser un avis sur le trai

{# /.split-detail #} + +{# ============================================================ + Quote preview modal — opened by the "Devis" button on the + "Devis accepte" card. Same shape as the per-quote modal on + /client/requests/; download routes through client.quote_pdf + for company-scope access control. + ============================================================ #} +{% if pdf_preview %} + +{% endif %} + {% endblock %} {% block scripts %} From efc3a6af1680ec6b42f6c092815b610a58880ab4 Mon Sep 17 00:00:00 2001 From: scttpr Date: Sun, 10 May 2026 17:59:57 +0200 Subject: [PATCH 5/7] refactor(orders): eager-load order_detail relationships via scoping options --- blueprints/caterer/orders.py | 29 ++++++++++++++++++----------- blueprints/client/orders.py | 19 ++++++++++++++----- blueprints/scoping.py | 22 ++++++++++++++++++---- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/blueprints/caterer/orders.py b/blueprints/caterer/orders.py index 2cd2b5f..2f80961 100644 --- a/blueprints/caterer/orders.py +++ b/blueprints/caterer/orders.py @@ -1,12 +1,14 @@ from flask import abort, flash, g, redirect, render_template, request, url_for from sqlalchemy import select +from sqlalchemy.orm import joinedload, selectinload from blueprints.middleware import login_required, role_required from blueprints.scoping import get_caterer_order from database import get_db from extensions import limiter -from models import MEAL_TYPE_LABELS, Order, OrderStatus, Quote +from models import MEAL_TYPE_LABELS, Order, OrderStatus, Quote, QuoteRequest from services import workflow +from services.quotes import build_pdf_preview # Filter tabs visible on /caterer/orders. Keys map to ?status= URL params, @@ -69,16 +71,21 @@ def orders_list(): @role_required("caterer") def order_detail(order_id): caterer = g.current_user.caterer - order = get_caterer_order(order_id, caterer.id) - _ = order.quote - _ = order.quote.quote_request - _ = order.quote.quote_request.company - _ = order.quote.quote_request.user - _ = order.payments - # Powers the "Devis" button + preview modal on this page — - # same pdf_preview dict the request-detail modal feeds on. - from services.quotes import build_pdf_preview - + order = get_caterer_order( + order_id, + caterer.id, + options=[ + joinedload(Order.quote).options( + selectinload(Quote.lines), + joinedload(Quote.caterer), + joinedload(Quote.quote_request).options( + joinedload(QuoteRequest.company), + joinedload(QuoteRequest.user), + ), + ), + selectinload(Order.payments), + ], + ) pdf_preview = ( build_pdf_preview( order.quote, order.quote.quote_request, order.quote.caterer diff --git a/blueprints/client/orders.py b/blueprints/client/orders.py index 08b281c..831e91e 100644 --- a/blueprints/client/orders.py +++ b/blueprints/client/orders.py @@ -1,5 +1,6 @@ from flask import abort, flash, g, redirect, render_template, request, url_for from sqlalchemy import select +from sqlalchemy.orm import joinedload, selectinload from blueprints.client._helpers import ORDER_STATUS_LABELS from blueprints.middleware import login_required, role_required @@ -8,6 +9,7 @@ from extensions import limiter from models import ( MEAL_TYPE_LABELS, + Caterer, CatererReview, Order, OrderStatus, @@ -15,6 +17,7 @@ QuoteRequest, ) from services import reviews as reviews_service +from services.quotes import build_pdf_preview # Filter tabs visible on /client/orders. Keys map to ?status= URL params, @@ -88,7 +91,17 @@ def orders_list(): def order_detail(order_id): user = g.current_user db = get_db() - order = get_company_order(order_id, user) + order = get_company_order( + order_id, + user, + options=[ + joinedload(Order.quote).options( + selectinload(Quote.lines), + joinedload(Quote.caterer).selectinload(Caterer.users), + joinedload(Quote.quote_request), + ), + ], + ) caterer = order.quote.caterer # `caterer_user` drives the "Envoyer un message" modal in the @@ -106,10 +119,6 @@ def order_detail(order_id): db, order=order, viewer=user ) - # Powers the "Devis" button + preview modal on this page — - # same pdf_preview dict the request-detail modal feeds on. - from services.quotes import build_pdf_preview - pdf_preview = ( build_pdf_preview(order.quote, order.quote.quote_request, caterer) if order.quote.lines diff --git a/blueprints/scoping.py b/blueprints/scoping.py index 4a6f047..e9b6af0 100644 --- a/blueprints/scoping.py +++ b/blueprints/scoping.py @@ -68,12 +68,16 @@ def get_company_request(request_id, user): return qr -def get_company_order(order_id, user): +def get_company_order(order_id, user, *, options=None): """Fetch an Order the `user` is allowed to see, or abort 404. Scoped via the underlying QuoteRequest: admins see all the company's orders, client_user sees only the orders flowing from QRs they themselves created. + + `options` is forwarded to `Select.options(...)` so callers can + eager-load relationships in a single round-trip rather than relying + on lazy loads. """ db = get_db() stmt = ( @@ -82,6 +86,8 @@ def get_company_order(order_id, user): .join(QuoteRequest, Quote.quote_request_id == QuoteRequest.id) .where(Order.id == order_id, QuoteRequest.company_id == user.company_id) ) + if options: + stmt = stmt.options(*options) own_only = own_requests_filter(user) if own_only is not None: stmt = stmt.where(own_only) @@ -168,15 +174,23 @@ def get_caterer_quote(qr_id, quote_id, caterer_id): return quote -def get_caterer_order(order_id, caterer_id): - """Fetch an Order whose Quote belongs to `caterer_id`, or abort 404.""" +def get_caterer_order(order_id, caterer_id, *, options=None): + """Fetch an Order whose Quote belongs to `caterer_id`, or abort 404. + + `options` is forwarded to `Select.options(...)` so callers can + eager-load relationships in a single round-trip rather than relying + on lazy loads. + """ db = get_db() - order = db.scalar( + stmt = ( select(Order) .join(Quote, Order.quote_id == Quote.id) .where(Order.id == order_id) .where(Quote.caterer_id == caterer_id) ) + if options: + stmt = stmt.options(*options) + order = db.scalar(stmt) if not order: abort(404) return order From faa1b15a9a827badba18e4a35b8e79cec64f7e6d Mon Sep 17 00:00:00 2001 From: scttpr Date: Sun, 10 May 2026 18:00:11 +0200 Subject: [PATCH 6/7] fix(admin): eager-load order_detail, add rate-limit + success log on quote_pdf --- blueprints/admin.py | 50 ++++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/blueprints/admin.py b/blueprints/admin.py index 3a12796..9dca712 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -1,4 +1,5 @@ import datetime +import logging from io import BytesIO from flask import ( @@ -15,14 +16,9 @@ from sqlalchemy import func, select from sqlalchemy.orm import joinedload, selectinload - -# Mirror of the cap on caterer/client routes — refuses to render a -# quote whose line items list is implausibly long. Stops a malicious -# row from saturating the WeasyPrint worker. -_MAX_PDF_LINES = 500 - from blueprints.middleware import login_required, role_required from database import get_db +from extensions import limiter from forms.caterer import AdminMessageForm, RejectionForm from models import ( MEAL_TYPE_LABELS, @@ -51,8 +47,16 @@ notify_users, ) from services.matching import find_matching_caterers +from services.quotes import build_pdf_preview from blueprints._notifications import register as _register_notifications +logger = logging.getLogger(__name__) + +# Mirror of the cap on caterer/client routes — refuses to render a +# quote whose line items list is implausibly long. Stops a malicious +# row from saturating the WeasyPrint worker. +_MAX_PDF_LINES = 500 + admin_bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -625,22 +629,22 @@ def orders_list(): @role_required("super_admin") def order_detail(order_id): db = get_db() - order = db.get(Order, order_id) + order = db.scalar( + select(Order) + .options( + joinedload(Order.quote).options( + selectinload(Quote.lines), + joinedload(Quote.caterer), + joinedload(Quote.quote_request).joinedload(QuoteRequest.company), + ), + selectinload(Order.payments), + ) + .where(Order.id == order_id) + ) if not order: abort(404) - _ = order.quote - _ = order.quote.quote_request - _ = order.quote.quote_request.company - _ = order.quote.caterer - _ = order.payments - # Powers the "Devis" button + preview modal on this page — - # same pdf_preview dict the request-detail modal feeds on. - from services.quotes import build_pdf_preview - pdf_preview = ( - build_pdf_preview( - order.quote, order.quote.quote_request, order.quote.caterer - ) + build_pdf_preview(order.quote, order.quote.quote_request, order.quote.caterer) if order.quote.lines else None ) @@ -656,6 +660,7 @@ def order_detail(order_id): @admin_bp.route("/quotes//pdf", methods=["GET"]) @login_required @role_required("super_admin") +@limiter.limit("20 per minute") def quote_pdf(q_id): """Download any quote as a server-rendered PDF (admin observer view). @@ -688,6 +693,13 @@ def quote_pdf(q_id): abort(413) pdf_bytes = render_quote_pdf(quote, quote.quote_request, quote.caterer) + logger.info( + "quote_pdf_downloaded admin_user_id=%s quote_id=%s reference=%s lines=%d", + g.current_user.id, + quote.id, + quote.reference, + len(quote.lines), + ) return send_file( BytesIO(pdf_bytes), mimetype="application/pdf", From ddc774857c49fa38093e0c8820e715d42b0985cf Mon Sep 17 00:00:00 2001 From: scttpr Date: Sun, 10 May 2026 18:00:15 +0200 Subject: [PATCH 7/7] refactor(orders): extract shared _order_quote_preview_modal partial --- templates/admin/orders/detail.html | 36 ++-------------- templates/caterer/orders/detail.html | 38 +---------------- templates/client/orders/detail.html | 38 +---------------- .../_order_quote_preview_modal.html | 42 +++++++++++++++++++ 4 files changed, 50 insertions(+), 104 deletions(-) create mode 100644 templates/components/_order_quote_preview_modal.html diff --git a/templates/admin/orders/detail.html b/templates/admin/orders/detail.html index 8d1f1f5..353fada 100644 --- a/templates/admin/orders/detail.html +++ b/templates/admin/orders/detail.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% from "components/status_badge.html" import status_badge %} +{% from "components/_order_quote_preview_modal.html" import order_quote_preview_modal %} {% block title %}Commande - Admin{% endblock %} @@ -156,38 +157,9 @@

Paiements

{% endif %}
-{# Quote preview modal — admin observer view, no scope needed for the - download (admin.quote_pdf serves any quote). #} +{# Admin observer view — admin.quote_pdf serves any quote without + scope check, so the download URL needs no extra context. #} {% if pdf_preview %} - +{{ order_quote_preview_modal(order, url_for('admin.quote_pdf', q_id=order.quote.id)) }} {% endif %} {% endblock %} diff --git a/templates/caterer/orders/detail.html b/templates/caterer/orders/detail.html index a47f02d..d8d0d4d 100644 --- a/templates/caterer/orders/detail.html +++ b/templates/caterer/orders/detail.html @@ -2,6 +2,7 @@ {% from "components/status_badge.html" import status_badge %} {% from "components/ui.html" import back_button, contact_card %} {% from "components/send_message_modal.html" import send_message_modal %} +{% from "components/_order_quote_preview_modal.html" import order_quote_preview_modal %} {% block title %}Commande - Les Traiteurs Engages{% endblock %} @@ -237,43 +238,8 @@

Montants

-{# ============================================================ - Quote preview modal — opened by the "Devis" button above. - Mirrors the per-quote modal on client/requests/detail.html: - same _pdf_preview.html partial + header with a Télécharger - button pointing at caterer.quote_pdf for this order's quote. - ============================================================ #} {% if pdf_preview %} - +{{ order_quote_preview_modal(order, url_for('caterer.quote_pdf', qr_id=order.quote.quote_request_id, q_id=order.quote.id)) }} {% endif %} {% endblock %} diff --git a/templates/client/orders/detail.html b/templates/client/orders/detail.html index 0172aa2..d98bfd5 100644 --- a/templates/client/orders/detail.html +++ b/templates/client/orders/detail.html @@ -3,6 +3,7 @@ {% from "components/structure_type_badge.html" import structure_type_label %} {% from "components/ui.html" import back_button %} {% from "components/send_message_modal.html" import send_message_modal %} +{% from "components/_order_quote_preview_modal.html" import order_quote_preview_modal %} {% block title %}Commande - Les Traiteurs Engages{% endblock %} @@ -315,43 +316,8 @@

Laisser un avis sur le trai -{# ============================================================ - Quote preview modal — opened by the "Devis" button on the - "Devis accepte" card. Same shape as the per-quote modal on - /client/requests/; download routes through client.quote_pdf - for company-scope access control. - ============================================================ #} {% if pdf_preview %} - +{{ order_quote_preview_modal(order, url_for('client.quote_pdf', request_id=order.quote.quote_request_id, q_id=order.quote.id)) }} {% endif %} {% endblock %} diff --git a/templates/components/_order_quote_preview_modal.html b/templates/components/_order_quote_preview_modal.html new file mode 100644 index 0000000..b12fd74 --- /dev/null +++ b/templates/components/_order_quote_preview_modal.html @@ -0,0 +1,42 @@ +{# Quote preview modal opened by the "Devis" button on order detail + pages. Same _pdf_preview.html partial as the request-detail modal, + so the in-app preview always matches what the role-appropriate + PDF route renders. + + Args: + order : the Order whose quote is being previewed. + download_url : role-specific URL for the "Télécharger" link + (caterer.quote_pdf / client.quote_pdf / + admin.quote_pdf), already resolved by the caller. #} +{% macro order_quote_preview_modal(order, download_url) %} + +{% endmacro %}