Compare commits

...

7 Commits

Author SHA1 Message Date
Jo a82dcc0c92 feat: export Indy — selection commandes, CSV produits/services, acronyme client
Deploy via Portainer / deploy (push) Successful in 0s
2026-07-17 00:15:26 +02:00
Jo 38fc4ed565 feat: jobs filters/sort/summary, prix HT/TTC/remise, poids piece, meshy fixes
Deploy via Portainer / deploy (push) Successful in 0s
2026-07-16 19:30:24 +02:00
Jo c00704a2aa feat: jobs page filters/sort/summary + poids piece in detail
Deploy via Portainer / deploy (push) Successful in 1s
2026-07-16 19:25:25 +02:00
Jo 0d146e241d fix: add missing meshy_margin_pct input in new_job form
Deploy via Portainer / deploy (push) Successful in 1s
2026-07-16 15:10:06 +02:00
Jo 0619559190 feat: Meshy.ai — raw cost in cout_fixe, margin in variable, per-job margin %
Deploy via Portainer / deploy (push) Successful in 1s
2026-07-16 14:54:13 +02:00
Jo 15ae5c5d77 feat: Meshy.ai cost integration — settings, new job form, price breakdown
Deploy via Portainer / deploy (push) Successful in 0s
2026-07-16 13:17:59 +02:00
Jo d4d51b205f docs: GUIDE + HANDOFF, docstrings app.py, fix syntax errors 2026-07-07 15:04:27 +02:00
11 changed files with 1153 additions and 47 deletions
+248
View File
@@ -0,0 +1,248 @@
# Guide — 3D Pricing App
Application web Flask/SQLite de gestion du pricing et de la planification pour un atelier d'impression 3D.
---
## Architecture technique
```
Flask (Python) ──► SQLite (WAL mode)
├── Templates Jinja2 (Bootstrap 5 + vis.js)
├── Thread arrière-plan : sync Home Assistant
└── Docker : /data/pricing.db (persistant)
```
**Stack :**
- Python 3.12 + Flask
- SQLite avec WAL (pas de blocage lecture/écriture simultanée)
- Bootstrap 5 + Bootstrap Icons
- vis.js Timeline (Gantt planning)
- Authelia OIDC (optionnel, activé via variables d'env)
- Home Assistant REST API (optionnel)
- Nextcloud WebDAV (optionnel)
---
## Schéma de base de données
| Table | Rôle |
|---|---|
| `settings` | Clés/valeurs de configuration globale |
| `clients` | Clients avec profils par défaut |
| `projects` | Projets regroupant plusieurs commandes |
| `jobs` | Commandes / calculs de prix (1 plateau = 1 job) |
| `materials` | Filaments avec stock et historique de prix |
| `material_price_history` | Historique des changements de prix filament |
| `machine_profiles` | Profils machine (usure, électricité) |
| `handling_profiles` | Profils manutention (taux horaire, minutes/plateau) |
| `material_profiles` | Profils marge matière |
| `pricing_profiles` | Profils multiplicateur design + marge brute |
| `printers` | Imprimantes physiques avec préfixe HA |
| `print_slots` | Créneaux d'impression planifiés |
| `working_schedule` | Planning hebdomadaire (TT / sur site / off) |
| `calendar_blocks` | Indisponibilités ponctuelles (plage de dates) |
---
## Moteur de calcul (`calc()`)
Tout se calcule **par pièce** à partir des données du plateau Bambu.
```
Coût fixe = matière + marge_matière + usure_machine + électricité
(divisé par pièces/plateau)
Sous-total = coût_fixe + manutention + design
Marge brute = sous-total × gross_margin_pct / 100
Total marge = manutention + design + marge_brute
Prix HT = coût_fixe + total_marge
Prix HT net = prix_ht / (1 - cotisations - VFL - autres_taxes)
└─ les charges fiscales sont "self-contained" dans le prix
Prix TTC = prix_ht_net × (1 + TVA)
Prix final = prix_ttc × (1 - remise)
```
**Multiplicateur design** : coefficient appliqué au coût matière pour valoriser le temps de modélisation. `×0.8` = design simple, `×1.8` = design complexe.
---
## Pages et fonctionnalités
### Dashboard (`/`)
Vue d'ensemble : 10 dernières commandes, CA total, CA du mois, alertes stock bas.
### Commandes (`/jobs`)
Liste de toutes les commandes avec réf. plateau, poids, durée, marge, remise, prix final.
- **Nouveau job** : saisie manuelle ou import `.gcode.3mf` depuis Nextcloud
- **Import Bambu** : parse `slice_info.config` → détecte les plateaux, poids, durée, filaments
- **Duplication** (`?clone_from=ID`) : copie les paramètres + re-parse automatiquement le fichier Nextcloud si `source_nc_path` est renseigné
- **Export CSV** : toutes les commandes en CSV
### Détail commande (`/jobs/<id>`)
- Décomposition du prix (coût fixe, variable, fiscal)
- Section **Plateaux d'impression** : liste des slots avec statut, pièces, avancement HA
- Portail client : génération d'un lien de partage tokenisé
### Projets (`/projects`)
Regroupe plusieurs commandes sous un même projet client. Affiche total pièces et CA.
### Clients (`/clients`)
Clients avec profils par défaut (machine, manutention, matière, tarif). Ces profils se chargent automatiquement à la création d'un job pour ce client.
### Matières (`/materials`)
- Stock en grammes avec alerte seuil bas
- Historique des prix
- Déduction automatique du stock à la création d'un job
- Suivi du gaspillage (impressions ratées)
### Profils (`/profiles`)
4 types de profils réutilisables :
- **Machine** : prix imprimante, durée de vie, nozzle, plateau, puissance, électricité
- **Manutention** : taux horaire, minutes par plateau
- **Matière** : marge matière en %
- **Tarif** : multiplicateur design + marge brute
### Planning (`/planning`)
Gantt vis.js sur 14 jours (démarre 12h avant maintenant pour voir les impressions en cours).
- **Gantt** : slots par imprimante, codes couleur par statut, bandes rouges si capacité saturée
- **À planifier** : jobs dont tous les plateaux ne sont pas encore planifiés
- **Overlay HA live** : rafraîchissement automatique (intervalle configurable) des statuts depuis Home Assistant
- **Badges compensation** : si HA signale des pièces ignorées sur un plateau, les autres plateaux du même job proposent d'augmenter leur quantité
**Planifier un slot :**
1. Cliquer sur un job "À planifier"
2. Choisir imprimante + date/heure de lancement
3. Option "Déjà imprimé" pour enregistrer rétroactivement sans vérification de capacité
**Statuts des slots :**
| Statut | Couleur | Signification |
|---|---|---|
| `planned` | bleu | Planifié, pas encore lancé |
| `running` | orange | En cours (mis à jour par HA sync) |
| `done` | vert | Terminé |
| `failed` | rouge | Raté (gaspillage logué) |
| `cancelled` | gris | Annulé |
### Imprimantes (`/printers`)
- Activer/désactiver des imprimantes
- Configurer le préfixe HA (`a1_1` → surveille `sensor.a1_1_etat_de_l_impression`)
- Modifier nom, préfixe HA, notes
### Planning horaire (`/working-schedule`)
Configuré par jour de la semaine :
- **TT** (télétravail) : fenêtre horaire avec nb de lancements calculé automatiquement
- **Onsite** : 2 créneaux fixes (ex: 07h00 + 18h30)
- **Off** : aucun lancement possible
### Indisponibilités (`/calendar-blocks`)
Bloquer une période (date début → date fin optionnelle) pour une ou toutes les imprimantes.
Types : Off / Télétravail / Sur site / Maintenance machine.
### Page mobile (`/mobile`)
Interface simplifiée pour saisie rapide sur smartphone :
- Ajouter une indisponibilité
- Décaler un créneau
- Marquer une impression comme ratée ou terminée
### Statistiques (`/stats`)
CA par période, matières les plus utilisées, gaspillage, top clients.
### Paramètres (`/settings`)
- Taux fiscaux (cotisations, VFL, TVA, autres taxes)
- Paramètres machine par défaut
- Configuration Nextcloud (URL, user, mot de passe d'app, dossier racine)
- Configuration Home Assistant (URL, token, intervalle de sync)
- Nombre max d'imprimantes simultanées
- Cooldown entre impressions (minutes)
---
## Home Assistant Sync
Le thread HA tourne en arrière-plan et poll toutes les N secondes (configurable, défaut 60s).
**Entités surveillées** (par imprimante avec préfixe `{prefix}`) :
- `sensor.{prefix}_etat_de_l_impression``running` / `finish` / `unknown`
- `binary_sensor.{prefix}_erreur_d_impression``on` / `off`
- `sensor.{prefix}_objets_ignores` → nombre de pièces ignorées (compensation multi-plateau)
- `sensor.{prefix}_avancement_de_l_impression` → % d'avancement
**Logique de sync :**
1. Cherche un slot `planned` ou `running` qui chevauche la fenêtre actuelle (±30 min)
2. `running` → passe le slot à `running`
3. `finish` → passe le slot à `done`
4. `binary_sensor erreur = on` → passe le slot à `failed`
5. `objets_ignores > 0` → met à jour `pieces_ignored` sur le slot
**Important** : le sync utilise des connexions courtes par imprimante (autocommit) pour ne pas bloquer les requêtes Flask.
---
## Nextcloud
Utilisé comme bibliothèque de fichiers `.gcode.3mf`.
- Navigation WebDAV via le picker modal dans "Nouveau job"
- Téléchargement + parsing du `.3mf` côté serveur (`/api/nextcloud/parse`)
- Chemin stocké dans `source_nc_path` pour permettre la duplication sans re-sélection
---
## Portail client
Chaque commande peut générer un lien tokenisé (`/portal/<token>`) donnant accès à :
- Décomposition du prix en lecture seule
- Sans authentification requise
---
## Variables d'environnement (Docker)
| Variable | Défaut | Rôle |
|---|---|---|
| `SECRET_KEY` | généré aléatoirement | Clé session Flask |
| `DATABASE_PATH` | `/data/pricing.db` | Chemin SQLite |
| `OIDC_CLIENT_ID` | — | Active l'auth OIDC si défini |
| `OIDC_CLIENT_SECRET` | — | Secret OIDC |
| `OIDC_DISCOVERY_URL` | — | URL metadata OIDC (Authelia) |
| `OIDC_END_SESSION_URL` | — | URL déconnexion OIDC |
---
## Calendrier iCal (`/calendar.ics`)
Export iCal des créneaux planifiés. Protégé par token (`ical_token` dans settings).
URL : `http://host/calendar.ics?token=<ical_token>`
---
## API JSON principales
| Endpoint | Méthode | Rôle |
|---|---|---|
| `/api/calculate` | POST | Calcul de prix live (formulaire new_job) |
| `/api/slots` | GET | Liste des slots pour le Gantt (7 derniers jours → +) |
| `/api/slots/new` | POST | Créer un slot planifié |
| `/api/slots/<id>/move` | POST | Déplacer un slot |
| `/api/slots/<id>/fail` | POST | Marquer comme raté |
| `/api/slots/<id>/done` | POST | Marquer comme terminé |
| `/api/slots/<id>/status` | POST | Changer le statut |
| `/api/slots/<id>/pieces_override` | POST | Override nb pièces (compensation) |
| `/api/suggest-slot` | POST | Suggérer le prochain créneau libre |
| `/api/capacity` | POST | Calculer la capacité de production |
| `/api/ha/status` | GET | Statuts HA de toutes les imprimantes |
| `/api/parse-3mf` | POST | Parser un fichier .3mf uploadé |
| `/api/nextcloud/browse` | GET | Lister un dossier Nextcloud |
| `/api/nextcloud/parse` | GET | Fetch + parser un .3mf Nextcloud |
| `/api/nextcloud/file` | GET | Proxy téléchargement fichier Nextcloud |
| `/api/client/<id>/defaults` | GET | Profils par défaut d'un client |
+207
View File
@@ -0,0 +1,207 @@
# Handoff — 3D Pricing App
Ce document permet de reprendre le développement dans un nouveau contexte de conversation.
Lire aussi `GUIDE.md` pour la documentation fonctionnelle complète.
---
## État du projet (07/07/2026)
Application **en production** sur le réseau local. Tous les bugs critiques connus sont résolus.
### Stack
- Python 3.12 / Flask / SQLite (WAL mode)
- Bootstrap 5 + vis.js Timeline
- Docker (port 5010 → 5000 interne)
- Données persistantes dans volume Docker `/data/pricing.db`
### Dépôt
```
C:\Users\jbper\Claude\3d-pricing\
├── app.py # ~2557 lignes — backend complet
├── templates/ # 27 templates Jinja2
├── GUIDE.md # Documentation fonctionnelle
├── HANDOFF.md # Ce fichier
├── Dockerfile
├── docker-compose.yml
└── requirements.txt # flask, authlib, requests
```
---
## Règle absolue : ne jamais éditer app.py avec l'outil Edit
Le fichier `app.py` est sur un montage NTFS (Windows) — l'outil `Edit` tronque le fichier silencieusement.
**Méthode obligatoire pour modifier app.py :**
```bash
# Toujours passer par un script Python et écrire directement via le chemin bash
python3 - << 'EOF'
with open('/sessions/festive-sharp-bardeen/mnt/3d-pricing/app.py', 'r') as f:
content = f.read()
content = content.replace(old, new, 1)
with open('/sessions/festive-sharp-bardeen/mnt/3d-pricing/app.py', 'w') as f:
f.write(content)
EOF
```
La même règle s'applique aux templates si besoin (mais ils sont plus petits, moins risqués).
---
## Migrations DB
Les migrations sont dans `init_db()` sous forme de liste `migrations` avec `try/except` par commande. **Ne jamais supprimer une migration existante** — l'ordre est cumulatif.
Dernières migrations ajoutées (à la fin de la liste) :
```python
'ALTER TABLE printers ADD COLUMN ha_entity_prefix TEXT DEFAULT ""'
'ALTER TABLE print_slots ADD COLUMN pieces_ignored INTEGER DEFAULT 0'
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL'
'ALTER TABLE calendar_blocks ADD COLUMN date_end TEXT DEFAULT NULL'
'ALTER TABLE jobs ADD COLUMN marge_pct_on_ht REAL DEFAULT 0'
'ALTER TABLE jobs ADD COLUMN source_nc_path TEXT DEFAULT ""'
'ALTER TABLE jobs ADD COLUMN meshy_enabled INTEGER DEFAULT 0'
'ALTER TABLE jobs ADD COLUMN meshy_credits_used INTEGER DEFAULT 0'
'ALTER TABLE jobs ADD COLUMN meshy_cost REAL DEFAULT 0'
'ALTER TABLE jobs ADD COLUMN meshy_margin_pct REAL DEFAULT 0'
```
---
## Fonctionnalités clés et leur implémentation
### Moteur de pricing (`calc()`, ligne ~458)
Calcul par pièce. `weight_g` et `print_time_s` = valeurs du **plateau complet** Bambu, divisées par `pieces_per_plate`. Les charges fiscales (cotisations, VFL, autres taxes) sont incluses dans le prix via `prix_ht_net = prix_ht / (1 - total_charges)`.
### Thread Home Assistant (`_ha_sync_once()`, ligne ~2103)
- Poll toutes les N secondes (setting `ha_poll_interval`, défaut 60s)
- **Connexions courtes par imprimante avec `isolation_level=None`** (autocommit) pour éviter `database is locked`
- Entités surveillées : `sensor.{prefix}_etat_de_l_impression`, `binary_sensor.{prefix}_erreur_d_impression`, `sensor.{prefix}_objets_ignores`, `sensor.{prefix}_avancement_de_l_impression`
### Multi-plateau
- `pieces_per_plate` sur le job = pièces par plateau physique
- `order_qty` = quantité totale commandée
- `total_plates_needed = ceil(order_qty / pieces_per_plate)` (division entière ceiling)
- "À planifier" sur le planning : affiche les jobs tant que `scheduled_plates < total_plates_needed`
- Compensation : si HA signale `pieces_ignored > 0` sur un slot, les autres slots du job affichent un badge amber pour override le nb de pièces
### Déjà imprimé (`already_done`)
Dans `POST /api/slots/new` : si `already_done=true`, les checks capacité/overlap sont **sautés entièrement**, slot inséré avec `status='done'`. L'ordre du check est important : `already_done` est testé **avant** `_check_overlap` et `_check_capacity`.
### Gantt (`planning.html`, ligne ~366)
```javascript
start: new Date(now.getTime() - 12 * 3600000), // -12h pour voir les impressions en cours
end: new Date(now.getTime() + 14 * 86400000),
```
### Indisponibilités par plage (`calendar_blocks`)
Colonne `date_end` optionnelle. Requêtes avec `COALESCE(date_end, date)` pour compatibilité ascendante.
### Duplication avec re-parse Nextcloud (`source_nc_path`)
- Nouveau champ DB `source_nc_path` : chemin WebDAV complet du fichier `.3mf`
- À la création d'un job depuis Nextcloud, le path est sauvegardé dans un hidden input
- Au chargement de `new_job` avec `clone_from`, `autoParseOnClone()` appelle `/api/nextcloud/parse?path=...` et reconstitue le sélecteur de plateaux
### Intégration Meshy.ai
- Abonnement annuel + crédits mensuels configurables dans Paramètres (`meshy_annual_cost`, `meshy_monthly_credits`, `meshy_default_margin_pct`)
- Coût brut : `meshy_raw = credits × (annual_cost / 12 / monthly_credits)` → va dans **cout_fixe** (comme l'électricité)
- Marge Meshy : `meshy_margin = meshy_raw × meshy_margin_pct / 100` → va dans **total_marge** (partie variable)
- La `gross_margin_pct` s'applique aussi sur `cout_fixe` qui contient `meshy_raw` : double levier intentionnel
- Champ `meshy_margin_pct` saisi par job (défaut = `meshy_default_margin_pct` dans settings)
- Colonne `meshy_cost` en DB = coût brut ; `meshy_margin_amount` = calculé à la volée (`meshy_cost × meshy_margin_pct / 100`)
- Dans `job_detail.html` : `meshy_cost` affiché dans section Cout fixe, `meshy_margin_amount` dans Partie variable
---
## Bugs résolus dans cette session
| Bug | Fix |
|---|---|
| `database is locked` lors de la création de job | WAL mode + connexions autocommit par imprimante dans le thread HA |
| Slot HA-synced invisible dans le Gantt | Gantt démarre 12h avant maintenant |
| Vérification capacité bloquait "Déjà imprimé" | Check `already_done` déplacé avant `_check_capacity` |
| Vérification capacité bloquait sur impressions en cours | Filtre `AND planned_end > datetime('now')` |
| "À planifier" ne montrait qu'un seul plateau | Requête compare `scheduled_plates < total_plates_needed` |
| `datetime.utcnow()` deprecated | Remplacé par `datetime.now(timezone.utc).replace(tzinfo=None)` |
| INSERT jobs : 36 `?` pour 37 colonnes | Ajout du `?` manquant pour `marge_pct_on_ht` |
| jobs.html tronqué après écriture Python | Toujours partir d'une version git saine avant modification |
---
## Points d'attention / dette technique
### Ce qui est propre
- WAL mode SQLite : pas de blocage lecture/écriture
- Thread HA : connexions courtes, autocommit, pas de transaction longue
- Migrations cumulatives dans `init_db()` avec `try/except` par ligne
- `login_required` décorateur + `require_login_global` pour protection globale si OIDC activé
### À surveiller
- **jobs.html** : toujours utiliser le script Python pour modifier, vérifier la syntaxe Jinja2 après chaque modif avec `python3 -c "import jinja2; ..."`
- **app.py** : après ajout de docstrings avec des accents, vérifier `python3 -m py_compile app.py` — les chaînes avec apostrophes dans les docstrings `"""` sont valides mais certaines substitutions de texte peuvent créer des conflits
- **INSERT jobs** : 42 colonnes = 42 `?`. Si on ajoute une colonne, ajouter aussi le `?` ET la valeur dans le tuple
### Fonctionnalité pas encore implémentée
- Recalcul de `marge_pct_on_ht` pour les jobs existants (valeur = 0 pour les anciens jobs)
- Stats : le gaspillage filament (`total_wasted_g`) n'est pas encore affiché dans les stats globales
- Page mobile : n'enregistre pas `source_nc_path` (pas critique)
---
## Commandes utiles
```bash
# Vérifier la syntaxe Python
python3 -m py_compile /sessions/festive-sharp-bardeen/mnt/3d-pricing/app.py
# Vérifier la syntaxe Jinja2 d'un template
python3 -c "
import jinja2
env = jinja2.Environment(loader=jinja2.FileSystemLoader('templates'))
env.parse(open('templates/jobs.html').read())
print('OK')
"
# Voir les routes Flask
grep -n "^@app.route" /sessions/festive-sharp-bardeen/mnt/3d-pricing/app.py
# Chercher dans app.py sans tronquer
grep -n "mot_clé" /sessions/festive-sharp-bardeen/mnt/3d-pricing/app.py
```
---
## Git
Le dépôt est sur Windows — les commandes git **doivent être exécutées depuis un terminal Windows**, pas depuis le shell Linux (le fichier `git/index.lock` bloque les commits depuis Linux).
```powershell
cd C:\Users\jbper\Claude\3d-pricing
git add -A
git commit -m "description"
```
Derniers commits :
```
(à committer) feat: Meshy.ai — raw cost in cout_fixe, margin in variable, per-job margin %
3da19f5 feat: auto-reparse Nextcloud 3mf on job clone
0069ab2 fix: INSERT jobs missing ? for marge_pct_on_ht
80307ac feat: plate_name and total_marge in jobs list
f51a789 fix: HA sync per-printer autocommit connections
522a0c5 fix: WAL mode SQLite
b55edce fix: skip capacity/overlap when already_done=true
7824124 fix: replace deprecated utcnow()
1899165 fix: gantt starts 12h before now
```
---
## Chemins importants
| Quoi | Chemin Windows | Chemin Linux (bash) |
|---|---|---|
| Dossier projet | `C:\Users\jbper\Claude\3d-pricing\` | `/sessions/festive-sharp-bardeen/mnt/3d-pricing/` |
| app.py | `C:\Users\jbper\Claude\3d-pricing\app.py` | `/sessions/festive-sharp-bardeen/mnt/3d-pricing/app.py` |
| Templates | `C:\Users\jbper\Claude\3d-pricing\templates\` | `/sessions/festive-sharp-bardeen/mnt/3d-pricing/templates/` |
| Base de données | Dans le container Docker : `/data/pricing.db` | — |
Binary file not shown.
+70 -15
View File
@@ -256,6 +256,9 @@ def init_db():
('ha_url',''), ('ha_url',''),
('ha_token',''), ('ha_token',''),
('ha_poll_interval','60'), ('ha_poll_interval','60'),
('meshy_annual_cost','200.0'),
('meshy_monthly_credits','1000'),
('meshy_default_margin_pct','30.0'),
] ]
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults) conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
@@ -302,6 +305,11 @@ def init_db():
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL', 'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
'ALTER TABLE calendar_blocks ADD COLUMN date_end TEXT DEFAULT NULL', 'ALTER TABLE calendar_blocks ADD COLUMN date_end TEXT DEFAULT NULL',
'ALTER TABLE jobs ADD COLUMN source_nc_path TEXT DEFAULT ""', 'ALTER TABLE jobs ADD COLUMN source_nc_path TEXT DEFAULT ""',
'ALTER TABLE jobs ADD COLUMN meshy_enabled INTEGER DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN meshy_credits_used INTEGER DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN meshy_cost REAL DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN meshy_margin_pct REAL DEFAULT 0',
'ALTER TABLE clients ADD COLUMN acronyme TEXT DEFAULT ""',
] ]
for sql in migrations: for sql in migrations:
try: try:
@@ -456,7 +464,8 @@ def parse_3mf(file_stream):
return result return result
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
price_per_kg_override=None, s=None, pieces_per_plate=1): price_per_kg_override=None, s=None, pieces_per_plate=1,
meshy_enabled=False, meshy_credits_used=0, meshy_margin_pct=0.0):
""" """
Moteur de pricing principal — calcule le prix de vente d'une pièce. Moteur de pricing principal — calcule le prix de vente d'une pièce.
@@ -509,10 +518,21 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
wear = printer_w + nozzle_w + plate_w wear = printer_w + nozzle_w + plate_w
elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh'] / pieces_per_plate elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh'] / pieces_per_plate
cout_fixe = mat + mat_margin + wear + elec # Coût Meshy.ai — coût brut dans cout_fixe, marge spécifique dans total_marge
meshy_unit = 0.0
meshy_raw = 0.0
meshy_margin_amount = 0.0
if meshy_enabled and meshy_credits_used > 0:
_meshy_annual = float(s.get('meshy_annual_cost', 200.0))
_meshy_credits = float(s.get('meshy_monthly_credits', 1000)) or 1
meshy_unit = _meshy_annual / 12 / _meshy_credits
meshy_raw = meshy_credits_used * meshy_unit
meshy_margin_amount = meshy_raw * (meshy_margin_pct / 100)
cout_fixe = mat + mat_margin + wear + elec + meshy_raw
sous_total = cout_fixe + handling + design sous_total = cout_fixe + handling + design
margin = sous_total * (gross_margin_pct / 100) margin = sous_total * (gross_margin_pct / 100)
total_marge= handling + design + margin total_marge= handling + design + meshy_margin_amount + margin
prix_ht = cout_fixe + total_marge prix_ht = cout_fixe + total_marge
cot_rate = s.get('cotisations_rate_pct', 12.3) / 100 cot_rate = s.get('cotisations_rate_pct', 12.3) / 100
@@ -557,6 +577,9 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
'tva_amount': round(tva_amount, 4), 'tva_amount': round(tva_amount, 4),
'prix_ht_net': round(prix_ht_net, 4), 'prix_ht_net': round(prix_ht_net, 4),
'prix_ttc': round(prix_ttc, 4), 'prix_ttc': round(prix_ttc, 4),
'meshy_cost': round(meshy_raw, 4),
'meshy_margin_amount': round(meshy_margin_amount, 4),
'meshy_unit_cost': round(meshy_unit, 6),
'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4), 'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4),
'final_price': round(final, 2), 'final_price': round(final, 2),
'_cot_pct': round(cot_rate * 100, 2), '_cot_pct': round(cot_rate * 100, 2),
@@ -823,10 +846,11 @@ def new_client():
if request.method == 'POST': if request.method == 'POST':
def _int(k): return int(request.form[k]) if request.form.get(k) else None def _int(k): return int(request.form[k]) if request.form.get(k) else None
conn.execute('''INSERT INTO clients conn.execute('''INSERT INTO clients
(name,email,notes,default_machine_profile_id,default_handling_profile_id, (name,acronyme,email,notes,default_machine_profile_id,default_handling_profile_id,
default_material_profile_id,default_pricing_profile_id) default_material_profile_id,default_pricing_profile_id)
VALUES(?,?,?,?,?,?,?)''', VALUES(?,?,?,?,?,?,?,?)''',
(request.form['name'], request.form.get('email',''), request.form.get('notes',''), (request.form['name'], request.form.get('acronyme','').upper().strip(),
request.form.get('email',''), request.form.get('notes',''),
_int('default_machine_profile_id'), _int('default_handling_profile_id'), _int('default_machine_profile_id'), _int('default_handling_profile_id'),
_int('default_material_profile_id'), _int('default_pricing_profile_id'))) _int('default_material_profile_id'), _int('default_pricing_profile_id')))
conn.commit(); conn.close() conn.commit(); conn.close()
@@ -841,10 +865,11 @@ def edit_client(id):
client = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone() client = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone()
if request.method == 'POST': if request.method == 'POST':
def _int(k): return int(request.form[k]) if request.form.get(k) else None def _int(k): return int(request.form[k]) if request.form.get(k) else None
conn.execute('''UPDATE clients SET name=?,email=?,notes=?, conn.execute('''UPDATE clients SET name=?,acronyme=?,email=?,notes=?,
default_machine_profile_id=?,default_handling_profile_id=?, default_machine_profile_id=?,default_handling_profile_id=?,
default_material_profile_id=?,default_pricing_profile_id=? WHERE id=?''', default_material_profile_id=?,default_pricing_profile_id=? WHERE id=?''',
(request.form['name'], request.form.get('email',''), request.form.get('notes',''), (request.form['name'], request.form.get('acronyme','').upper().strip(),
request.form.get('email',''), request.form.get('notes',''),
_int('default_machine_profile_id'), _int('default_handling_profile_id'), _int('default_machine_profile_id'), _int('default_handling_profile_id'),
_int('default_material_profile_id'), _int('default_pricing_profile_id'), id)) _int('default_material_profile_id'), _int('default_pricing_profile_id'), id))
conn.commit(); conn.close() conn.commit(); conn.close()
@@ -875,6 +900,25 @@ def delete_client(id):
conn.commit(); conn.close() conn.commit(); conn.close()
return redirect(url_for('clients')) return redirect(url_for('clients'))
@app.route('/export/indy')
def export_indy():
"""Page d'export des commandes au format Indy (produits/services)."""
conn = get_db()
jobs = conn.execute('''
SELECT j.id, j.name, j.created_at, j.final_price, j.discount_pct,
j.tva_amount, j.order_qty, j.pieces_per_plate, j.plate_name,
j.description,
c.name as client_name, c.acronyme as client_acronyme
FROM jobs j
LEFT JOIN clients c ON j.client_id = c.id
ORDER BY j.created_at DESC
''').fetchall()
s = get_settings()
tva_pct = float(s.get('tva_rate_pct', 20.0))
conn.close()
return render_template('export_indy.html', jobs=jobs, tva_pct=tva_pct)
# ─── Projets ────────────────────────────────────────────────────────────────── # ─── Projets ──────────────────────────────────────────────────────────────────
@app.route('/projects') @app.route('/projects')
@@ -1230,14 +1274,13 @@ def jobs():
conn = get_db() conn = get_db()
jobs = conn.execute(''' jobs = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name, SELECT j.*, c.name as client_name, m.name as material_name,
ps.status as slot_status, ps.planned_start COALESCE(j.cout_fixe, 0) + COALESCE(j.total_marge, 0) as prix_ht_piece
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id LEFT JOIN materials m ON j.material_id=m.id
LEFT JOIN print_slots ps ON ps.job_id=j.id AND ps.id=(
SELECT id FROM print_slots WHERE job_id=j.id ORDER BY planned_start LIMIT 1)
ORDER BY j.created_at DESC''').fetchall() ORDER BY j.created_at DESC''').fetchall()
clients = conn.execute('SELECT id, name FROM clients ORDER BY name').fetchall()
conn.close() conn.close()
return render_template('jobs.html', jobs=jobs) return render_template('jobs.html', jobs=jobs, clients=clients)
@app.route('/jobs/new', methods=['GET','POST']) @app.route('/jobs/new', methods=['GET','POST'])
def new_job(): def new_job():
@@ -1311,19 +1354,26 @@ def new_job():
if mat: if mat:
price_kg = mat['price_per_kg'] price_kg = mat['price_per_kg']
meshy_enabled_v = bool(request.form.get('meshy_enabled'))
meshy_credits_v = max(0, int(request.form.get('meshy_credits_used', 0) or 0))
meshy_margin_v = max(0.0, float(request.form.get('meshy_margin_pct', 0) or 0))
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount, r = calc(weight_g, print_time_s, design_mult, gross_margin, discount,
price_kg, s, pieces_per_plate) price_kg, s, pieces_per_plate,
meshy_enabled=meshy_enabled_v, meshy_credits_used=meshy_credits_v,
meshy_margin_pct=meshy_margin_v)
conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id, conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id,
material_profile_id,pricing_profile_id,project_id,source_file,source_nc_path,plate_name,name,description, material_profile_id,pricing_profile_id,project_id,source_file,source_nc_path,plate_name,name,description,
meshy_enabled,meshy_credits_used,meshy_cost,meshy_margin_pct,
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct, weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
pieces_per_plate,order_qty, pieces_per_plate,order_qty,
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost, material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht, cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht,
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount, cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
price_per_piece,final_price,notes) price_per_piece,final_price,notes)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id, (client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id,
source_file,source_nc_path,plate_name,request.form['name'],request.form.get('description',''), source_file,source_nc_path,plate_name,request.form['name'],request.form.get('description',''),
int(meshy_enabled_v),meshy_credits_v,r['meshy_cost'],meshy_margin_v,
weight_g,print_time_s,design_mult,gross_margin,discount, weight_g,print_time_s,design_mult,gross_margin,discount,
pieces_per_plate,order_qty, pieces_per_plate,order_qty,
r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'], r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'],
@@ -1687,6 +1737,9 @@ def api_calculate():
pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1))) pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1)))
order_qty = max(1, int(d.get('order_qty', 1))) order_qty = max(1, int(d.get('order_qty', 1)))
print_time_s = int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60 print_time_s = int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60
meshy_enabled = bool(d.get('meshy_enabled', False))
meshy_credits = max(0, int(d.get('meshy_credits_used', 0)))
meshy_margin_p = max(0.0, float(d.get('meshy_margin_pct', 0)))
result = calc( result = calc(
weight_g=float(d['weight_g']), weight_g=float(d['weight_g']),
@@ -1695,7 +1748,9 @@ def api_calculate():
gross_margin_pct=float(d.get('gross_margin_pct',27.5)), gross_margin_pct=float(d.get('gross_margin_pct',27.5)),
discount_pct=float(d.get('discount_pct',0)), discount_pct=float(d.get('discount_pct',0)),
price_per_kg_override=price_kg, s=s, price_per_kg_override=price_kg, s=s,
pieces_per_plate=pieces_per_plate) pieces_per_plate=pieces_per_plate,
meshy_enabled=meshy_enabled, meshy_credits_used=meshy_credits,
meshy_margin_pct=meshy_margin_p)
# Multi-piece extras # Multi-piece extras
nb_plateaux = math.ceil(order_qty / pieces_per_plate) nb_plateaux = math.ceil(order_qty / pieces_per_plate)
+3
View File
@@ -213,6 +213,9 @@
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}" title="Statistiques"> <a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}" title="Statistiques">
<i class="bi bi-bar-chart-line"></i><span class="nav-label"> Statistiques</span> <i class="bi bi-bar-chart-line"></i><span class="nav-label"> Statistiques</span>
</a> </a>
<a href="{{ url_for('export_indy') }}" class="{% if request.endpoint == 'export_indy' %}active{% endif %}" title="Export Indy">
<i class="bi bi-file-earmark-arrow-down"></i><span class="nav-label"> Export Indy</span>
</a>
<a href="{{ url_for('export_csv') }}" title="Export CSV"> <a href="{{ url_for('export_csv') }}" title="Export CSV">
<i class="bi bi-download"></i><span class="nav-label"> Export CSV</span> <i class="bi bi-download"></i><span class="nav-label"> Export CSV</span>
</a> </a>
+13 -4
View File
@@ -15,10 +15,19 @@
<div class="card-body"> <div class="card-body">
<form method="POST"> <form method="POST">
<div class="mb-3"> <div class="row g-3 mb-3">
<label class="form-label fw-semibold">Nom *</label> <div class="col-md-8">
<input type="text" name="name" class="form-control" required <label class="form-label fw-semibold">Nom *</label>
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp"> <input type="text" name="name" class="form-control" required
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Acronyme <span class="text-muted fw-normal">(export Indy)</span></label>
<input type="text" name="acronyme" class="form-control text-uppercase"
maxlength="10" value="{{ client.acronyme if client else '' }}"
placeholder="ex: BLK"
oninput="this.value=this.value.toUpperCase()">
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Email</label> <label class="form-label fw-semibold">Email</label>
+296
View File
@@ -0,0 +1,296 @@
{% extends 'base.html' %}
{% block title %}Export Indy{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-file-earmark-arrow-down me-2 text-primary"></i>Export Indy</h1>
<a href="{{ url_for('jobs') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Retour commandes
</a>
</div>
<div class="row g-4">
<!-- COLONNE GAUCHE : selection des commandes -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-check2-square me-1"></i>Selection des commandes</span>
<div class="d-flex gap-2">
<button id="selAll" class="btn btn-sm btn-outline-secondary py-0">Tout</button>
<button id="selNone" class="btn btn-sm btn-outline-secondary py-0">Aucun</button>
</div>
</div>
<!-- Filtres -->
<div class="card-body border-bottom py-2 px-3">
<div class="row g-2">
<div class="col">
<input type="text" id="fSearch" class="form-control form-control-sm" placeholder="Rechercher...">
</div>
<div class="col-auto">
<select id="fClientSel" class="form-select form-select-sm">
<option value="">Tous clients</option>
{% set seen = [] %}
{% for j in jobs %}
{% if j.client_name and j.client_name not in seen %}
<option value="{{ j.client_name }}">{{ j.client_name }}</option>
{% if seen.append(j.client_name) %}{% endif %}
{% endif %}
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Liste jobs -->
<div class="card-body p-0" style="max-height:520px;overflow-y:auto">
<table class="table table-sm table-hover mb-0" id="jobSelTable">
<thead class="table-light sticky-top">
<tr>
<th style="width:32px"><input type="checkbox" id="chkAll" class="form-check-input"></th>
<th>Commande</th>
<th class="text-end">Prix HT</th>
</tr>
</thead>
<tbody id="jobSelBody">
{% for j in jobs %}
{% set prix_ttc = (j.final_price / (1 - j.discount_pct / 100)) if (j.discount_pct and j.discount_pct > 0 and j.discount_pct < 100) else (j.final_price or 0) %}
{% set prix_ht_net = prix_ttc - (j.tva_amount or 0) %}
{% set qty = j.order_qty or 1 %}
{% set acro = j.client_acronyme or '' %}
{% set default_nom = (acro ~ ' - ' ~ j.name) if acro else j.name %}
<tr class="sel-row"
data-id="{{ j.id }}"
data-client="{{ j.client_name or '' }}"
data-nom="{{ default_nom }}"
data-prix-ht="{{ '%.2f'|format(prix_ht_net) }}"
data-tva="{{ tva_pct }}"
data-qty="{{ qty }}"
data-desc="{{ j.description or '' }}"
data-date="{{ j.created_at[:10] }}"
>
<td><input type="checkbox" class="form-check-input job-chk"></td>
<td>
<div class="fw-semibold" style="font-size:.85rem">{{ j.name }}</div>
<div class="text-muted" style="font-size:.72rem">
{{ j.created_at[:10] }}{% if j.client_name %} · {{ j.client_name }}{% if acro %} <span class="badge bg-light text-dark border">{{ acro }}</span>{% endif %}{% endif %}
</div>
</td>
<td class="text-end small fw-semibold">{{ '%.2f'|format(prix_ht_net) }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- COLONNE DROITE : tableau d'export éditable -->
<div class="col-lg-7">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-table me-1"></i>Produits Indy <span id="exportCount" class="badge bg-secondary ms-1">0</span></span>
<button id="btnDownload" class="btn btn-sm btn-success" disabled>
<i class="bi bi-download me-1"></i>Télécharger CSV Indy
</button>
</div>
<div class="card-body p-0" style="max-height:600px;overflow-y:auto">
<table class="table table-sm mb-0" id="exportTable">
<thead class="table-light sticky-top">
<tr>
<th>Nom produit <small class="text-muted fw-normal">(éditable)</small></th>
<th style="width:110px">Nature</th>
<th style="width:90px">Prix HT</th>
<th style="width:70px">TVA %</th>
<th style="width:60px">Unité</th>
<th style="width:28px"></th>
</tr>
</thead>
<tbody id="exportBody">
<tr id="exportEmpty">
<td colspan="6" class="text-center text-muted py-4">
<i class="bi bi-arrow-left me-1"></i>Sélectionnez des commandes
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="form-text mt-2 ps-1">
<i class="bi bi-info-circle me-1"></i>
Les colonnes <strong>Nom</strong>, <strong>Nature</strong>, <strong>Prix HT</strong>, <strong>TVA</strong> et <strong>Unité</strong> sont éditables directement dans le tableau.
Le CSV généré est compatible avec l'import <em>Produits &amp; Services</em> d'Indy.
</div>
</div>
</div>
<script>
(function() {
var rows = Array.from(document.querySelectorAll('.sel-row'));
var expBody = document.getElementById('exportBody');
var expEmpty = document.getElementById('exportEmpty');
var expCount = document.getElementById('exportCount');
var btnDl = document.getElementById('btnDownload');
var fSearch = document.getElementById('fSearch');
var fClient = document.getElementById('fClientSel');
var chkAll = document.getElementById('chkAll');
// Map jobId -> export row element
var exportRows = {};
function filterRows() {
var q = fSearch.value.toLowerCase();
var cli = fClient.value;
rows.forEach(function(r) {
var name = (r.dataset.nom + ' ' + r.dataset.client + ' ' + r.dataset.date).toLowerCase();
var show = (!q || name.includes(q)) && (!cli || r.dataset.client === cli);
r.style.display = show ? '' : 'none';
});
}
function makeInput(val, cls) {
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm border-0 px-1 ' + (cls||'');
inp.value = val;
inp.style.minWidth = '0';
return inp;
}
function makeSelect(opts, selected) {
var sel = document.createElement('select');
sel.className = 'form-select form-select-sm border-0 px-1';
opts.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o; opt.textContent = o;
if (o === selected) opt.selected = true;
sel.appendChild(opt);
});
return sel;
}
function addExportRow(r) {
if (exportRows[r.dataset.id]) return;
var tr = document.createElement('tr');
tr.dataset.id = r.dataset.id;
// Nom
var tdNom = document.createElement('td');
tdNom.appendChild(makeInput(r.dataset.nom, 'nom-inp'));
tr.appendChild(tdNom);
// Nature
var tdNat = document.createElement('td');
tdNat.appendChild(makeSelect(['Prestation de services', 'Vente de produits'], 'Prestation de services'));
tr.appendChild(tdNat);
// Prix HT
var tdPrix = document.createElement('td');
var prixInp = makeInput(r.dataset.prixHt, 'text-end');
tdPrix.appendChild(prixInp);
tr.appendChild(tdPrix);
// TVA
var tdTva = document.createElement('td');
tdTva.appendChild(makeInput(r.dataset.tva, 'text-end'));
tr.appendChild(tdTva);
// Unite
var tdUnit = document.createElement('td');
tdUnit.appendChild(makeSelect(['unite', 'piece', 'lot', 'heure'], 'unite'));
tr.appendChild(tdUnit);
// Remove
var tdDel = document.createElement('td');
var btn = document.createElement('button');
btn.className = 'btn btn-sm btn-link text-danger p-0';
btn.innerHTML = '<i class="bi bi-x-lg"></i>';
btn.onclick = function() {
// uncheck the source row
var srcRow = rows.find(function(rr) { return rr.dataset.id === r.dataset.id; });
if (srcRow) srcRow.querySelector('.job-chk').checked = false;
removeExportRow(r.dataset.id);
};
tdDel.appendChild(btn);
tr.appendChild(tdDel);
exportRows[r.dataset.id] = tr;
expBody.appendChild(tr);
updateEmpty();
}
function removeExportRow(id) {
var tr = exportRows[id];
if (tr) { tr.remove(); delete exportRows[id]; }
updateEmpty();
}
function updateEmpty() {
var count = Object.keys(exportRows).length;
expCount.textContent = count;
btnDl.disabled = count === 0;
if (count === 0) {
if (!expEmpty.parentNode) expBody.appendChild(expEmpty);
} else {
if (expEmpty.parentNode) expEmpty.remove();
}
}
// Checkbox on job rows
rows.forEach(function(r) {
var chk = r.querySelector('.job-chk');
chk.addEventListener('change', function() {
if (chk.checked) addExportRow(r);
else removeExportRow(r.dataset.id);
});
});
// Select all / none visible
document.getElementById('selAll').addEventListener('click', function() {
rows.filter(function(r) { return r.style.display !== 'none'; }).forEach(function(r) {
var chk = r.querySelector('.job-chk');
if (!chk.checked) { chk.checked = true; addExportRow(r); }
});
});
document.getElementById('selNone').addEventListener('click', function() {
rows.forEach(function(r) {
var chk = r.querySelector('.job-chk');
if (chk.checked) { chk.checked = false; removeExportRow(r.dataset.id); }
});
});
chkAll.addEventListener('change', function() {
if (chkAll.checked) document.getElementById('selAll').click();
else document.getElementById('selNone').click();
});
// Filters
fSearch.addEventListener('input', filterRows);
fClient.addEventListener('change', filterRows);
// CSV download
btnDl.addEventListener('click', function() {
var lines = ['"Nom*","Nature*","Prix unitaire HT*","Taux de TVA*","Description","Unite","Reference"'];
Object.values(exportRows).forEach(function(tr) {
var inputs = tr.querySelectorAll('input,select');
var nom = inputs[0].value.replace(/"/g,'""');
var nature = inputs[1].value.replace(/"/g,'""');
var prix = parseFloat(inputs[2].value).toFixed(2).replace('.',',');
var tva = inputs[3].value.replace(/"/g,'""');
var unite = inputs[4].value.replace(/"/g,'""');
lines.push('"'+nom+'","'+nature+'","'+prix+'","'+tva+'%","","'+unite+'",""');
});
var csv = '' + lines.join('\r\n');
var blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'indy_produits_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
URL.revokeObjectURL(url);
});
updateEmpty();
})();
</script>
{% endblock %}
+7
View File
@@ -82,6 +82,7 @@
{% endif %} {% endif %}
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr> <tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr> <tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
<tr><th class="text-muted fw-normal">Poids / pièce</th><td><strong>{{ '%.2f'|format(job.weight_g / (job.pieces_per_plate or 1)) }} g</strong></td></tr>
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr> <tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
<tr><th class="text-muted fw-normal">Quantite commandee</th><td>{{ qty }} pièce{{ 's' if qty > 1 else '' }}</td></tr> <tr><th class="text-muted fw-normal">Quantite commandee</th><td>{{ qty }} pièce{{ 's' if qty > 1 else '' }}</td></tr>
<tr><th class="text-muted fw-normal">Duree plateau</th><td>{{ job.print_time_s | fmt_time }}</td></tr> <tr><th class="text-muted fw-normal">Duree plateau</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
@@ -162,6 +163,9 @@
{{ brow('Marge matiere', job.material_margin, 'breakdown-muted ps-2', 4) }} {{ brow('Marge matiere', job.material_margin, 'breakdown-muted ps-2', 4) }}
{{ brow('Usure machine', job.wear_cost, 'breakdown-muted ps-2', 4) }} {{ brow('Usure machine', job.wear_cost, 'breakdown-muted ps-2', 4) }}
{{ brow('Electricite', job.electricity_cost, 'breakdown-muted ps-2', 4) }} {{ brow('Electricite', job.electricity_cost, 'breakdown-muted ps-2', 4) }}
{% if job.meshy_enabled and job.meshy_cost %}
{{ brow('Meshy.ai (' ~ job.meshy_credits_used ~ ' crédits)', job.meshy_cost, 'breakdown-muted ps-2', 4) }}
{% endif %}
<div class="breakdown-row subtotal"> <div class="breakdown-row subtotal">
<span class="flex-fill fw-semibold">Total cout fixe</span> <span class="flex-fill fw-semibold">Total cout fixe</span>
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(cout_f) }} €</span> <span class="text-end" style="min-width:85px">{{ "%.2f"|format(cout_f) }} €</span>
@@ -172,6 +176,9 @@
<div class="breakdown-row section-header"><span>Partie variable</span></div> <div class="breakdown-row section-header"><span>Partie variable</span></div>
{{ brow('Manutention', job.handling_cost, 'breakdown-muted ps-2', 4) }} {{ brow('Manutention', job.handling_cost, 'breakdown-muted ps-2', 4) }}
{{ brow('Design (×' ~ job.design_multiplier ~ ')', job.design_cost, 'breakdown-muted ps-2', 4) }} {{ brow('Design (×' ~ job.design_multiplier ~ ')', job.design_cost, 'breakdown-muted ps-2', 4) }}
{% if job.meshy_enabled and job.meshy_cost and job.meshy_margin_pct %}
{{ brow('Marge Meshy.ai (' ~ job.meshy_margin_pct ~ '%)', job.meshy_cost * job.meshy_margin_pct / 100, 'breakdown-muted ps-2', 4) }}
{% endif %}
{{ brow('Marge brute (' ~ job.gross_margin_pct ~ '%)', job.margin_amount, 'breakdown-muted ps-2') }} {{ brow('Marge brute (' ~ job.gross_margin_pct ~ '%)', job.margin_amount, 'breakdown-muted ps-2') }}
<div class="breakdown-row subtotal"> <div class="breakdown-row subtotal">
<span class="flex-fill fw-semibold">Total marge <small class="text-muted fw-normal">({{ marge_pct_ht }}% du HT)</small></span> <span class="flex-fill fw-semibold">Total marge <small class="text-muted fw-normal">({{ marge_pct_ht }}% du HT)</small></span>
+216 -26
View File
@@ -14,46 +14,160 @@
</div> </div>
</div> </div>
<!-- Barre de filtres -->
<div class="card mb-3">
<div class="card-body py-2 px-3">
<div class="row g-2 align-items-center">
<div class="col-auto d-flex align-items-center gap-1">
<label class="text-muted small mb-0">Du</label>
<input type="date" id="fDateFrom" class="form-control form-control-sm" style="width:140px">
</div>
<div class="col-auto d-flex align-items-center gap-1">
<label class="text-muted small mb-0">au</label>
<input type="date" id="fDateTo" class="form-control form-control-sm" style="width:140px">
</div>
<div class="col-auto">
<select id="fClient" class="form-select form-select-sm" style="min-width:160px">
<option value="">Tous les clients</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto d-flex align-items-center gap-1">
<label class="text-muted small mb-0">Tri</label>
<select id="fSort" class="form-select form-select-sm" style="min-width:165px">
<option value="date-desc">Date (recent)</option>
<option value="date-asc">Date (ancien)</option>
<option value="prix-desc">Prix HT desc</option>
<option value="prix-asc">Prix HT asc</option>
<option value="poids-desc">Poids desc</option>
<option value="poids-asc">Poids asc</option>
<option value="duree-desc">Duree desc</option>
<option value="duree-asc">Duree asc</option>
</select>
</div>
<div class="col-auto ms-auto">
<button id="fReset" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Reinitialiser
</button>
</div>
</div>
</div>
</div>
<!-- Resume -->
<div class="row g-2 mb-3">
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Commandes</div>
<div class="fw-bold fs-5" id="sumCount">{{ jobs|length }}</div>
</div>
</div>
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Total HT commandes</div>
<div class="fw-bold fs-5" id="sumHT">--</div>
</div>
</div>
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Total prix final</div>
<div class="fw-bold fs-5" id="sumFinal" style="color:#ff6b35">--</div>
</div>
</div>
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Poids total</div>
<div class="fw-bold fs-5" id="sumPoids">--</div>
</div>
</div>
</div>
<!-- Tableau -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
{% if jobs %} {% if jobs %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0" id="jobsTable">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Date</th><th>Nom</th><th>Client</th><th>Réf. plateau</th><th>Poids</th> <th style="width:90px">Date</th>
<th>Durée</th><th>Design</th><th>Marge brute</th><th>Total marge</th><th>Remise</th> <th>Nom</th>
<th class="text-end">Prix final</th><th></th> <th>Client</th>
<th>Matiere</th>
<th>Poids</th>
<th>Duree</th>
<th class="text-end">Prix HT</th>
<th class="text-end">Prix TTC</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="jobsBody">
{% for j in jobs %} {% for j in jobs %}
<tr> {% set pieces = j.pieces_per_plate or 1 %}
<td class="text-muted small">{{ j.created_at[:10] }}</td> {% set qty = j.order_qty or 1 %}
<td><a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a></td> {% set prix_ht = j.prix_ht_piece or 0 %}
<td>{{ j.client_name or '—' }}</td> {% set poids_piece = j.weight_g / pieces %}
<td class="text-muted small">{{ j.plate_name or '—' }}</td> <tr
<td>{{ j.weight_g }} g</td> data-date="{{ j.created_at[:10] }}"
<td>{{ j.print_time_s | fmt_time }}</td> data-client="{{ j.client_id or '' }}"
<td><span class="badge bg-light text-dark">×{{ j.design_multiplier }}</span></td> data-prix="{{ '%.4f' | format(prix_ht * qty) }}"
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</span></td> data-poids="{{ j.weight_g }}"
<td class="text-nowrap small">{% if j.total_marge %}{{ '%.2f'|format(j.total_marge) }} €<br><span class="text-muted">{% if j.marge_pct_on_ht %}{{ j.marge_pct_on_ht }}% du HT{% endif %}</span>{% else %}—{% endif %}</td> data-duree="{{ j.print_time_s }}"
<td> data-final="{{ '%.2f' | format((j.final_price or 0) * qty) }}"
{% if j.discount_pct > 0 %} >
<span class="badge bg-danger">-{{ j.discount_pct }}%</span> <td class="text-muted small align-middle">{{ j.created_at[:10] }}</td>
{% else %}—{% endif %} <td class="align-middle">
<a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a>
{% if j.plate_name %}<br><span class="text-muted" style="font-size:.72rem">{{ j.plate_name }}</span>{% endif %}
</td> </td>
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }}</td> <td class="align-middle small">{{ j.client_name or '--' }}</td>
<td> <td class="align-middle small">
{% if j.material_name %}<span class="badge bg-light text-dark border">{{ j.material_name }}</span>{% else %}<span class="text-muted">--</span>{% endif %}
</td>
<td class="align-middle">
<span class="fw-semibold">{{ j.weight_g }} g</span>
{% if pieces > 1 %}
<br><span class="text-muted" style="font-size:.72rem">{{ '%.1f'|format(poids_piece) }} g/pce x{{ pieces }}</span>
{% else %}
<br><span class="text-muted" style="font-size:.72rem">{{ '%.1f'|format(poids_piece) }} g/pce</span>
{% endif %}
</td>
<td class="align-middle small">{{ j.print_time_s | fmt_time }}</td>
{% set prix_ttc = (j.final_price / (1 - j.discount_pct / 100)) if (j.discount_pct and j.discount_pct > 0 and j.discount_pct < 100) else (j.final_price or 0) %}
<td class="align-middle text-end">
{% if prix_ht > 0 %}
<span class="fw-semibold">{{ '%.2f'|format(prix_ht) }} &euro;</span><span class="text-muted" style="font-size:.72rem"> HT/pce</span>
{% if qty > 1 %}
<br><span class="text-muted" style="font-size:.78rem">{{ '%.2f'|format(prix_ht * qty) }} &euro; x{{ qty }}</span>
{% endif %}
{% else %}--{% endif %}
</td>
<td class="align-middle text-end">
<span class="fw-semibold">{{ '%.2f'|format(prix_ttc) }} &euro;</span><span class="text-muted" style="font-size:.72rem"> TTC</span>
{% if j.discount_pct and j.discount_pct > 0 %}
<br><span class="fw-bold" style="color:#ff6b35">{{ '%.2f'|format(j.final_price or 0) }} &euro;</span>
<span class="badge bg-danger" style="font-size:.62rem">-{{ j.discount_pct }}%</span>
{% endif %}
{% if qty > 1 %}
<br><span class="text-muted" style="font-size:.72rem">
{{ '%.2f'|format(prix_ttc * qty) }} &euro; TTC tot.
{% if j.discount_pct and j.discount_pct > 0 %}
&rarr; <span style="color:#ff6b35">{{ '%.2f'|format((j.final_price or 0) * qty) }} &euro;</span>
{% endif %}
</span>
{% endif %}
</td>
<td class="align-middle">
<div class="d-flex gap-1 align-items-center"> <div class="d-flex gap-1 align-items-center">
{% if j.client_token %} {% if j.client_token %}
<a href="/portal/{{ j.client_token }}" target="_blank" <a href="/portal/{{ j.client_token }}" target="_blank"
class="btn btn-sm btn-outline-success py-0" class="btn btn-sm btn-outline-success py-0" title="Portail client">
title="Portail client actif — ouvrir">
<i class="bi bi-share-fill"></i> <i class="bi bi-share-fill"></i>
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0"></a> <a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0">&rarr;</a>
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}" <form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
onsubmit="return confirm('Supprimer ?')"> onsubmit="return confirm('Supprimer ?')">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
@@ -65,13 +179,89 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="noResults" class="text-center text-muted py-4 d-none">
<i class="bi bi-search fs-2"></i>
<p class="mt-2 mb-0">Aucune commande ne correspond aux filtres.</p>
</div>
{% else %} {% else %}
<div class="text-center text-muted py-5"> <div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1"></i> <i class="bi bi-inbox fs-1"></i>
<p class="mt-2">Aucune commande enregistrée.</p> <p class="mt-2">Aucune commande enregistree.</p>
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Créer le premier calcul</a> <a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Creer le premier calcul</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script>
(function() {
var body = document.getElementById('jobsBody');
if (!body) return;
var rows = Array.from(body.querySelectorAll('tr'));
var fFrom = document.getElementById('fDateFrom');
var fTo = document.getElementById('fDateTo');
var fCli = document.getElementById('fClient');
var fSort = document.getElementById('fSort');
var noRes = document.getElementById('noResults');
function fmt(v) {
return new Intl.NumberFormat('fr-FR', {minimumFractionDigits:2, maximumFractionDigits:2}).format(v) + ' €';
}
function fmtG(v) {
return new Intl.NumberFormat('fr-FR', {maximumFractionDigits:0}).format(v) + ' g';
}
function apply() {
var from = fFrom.value;
var to = fTo.value;
var client = fCli.value;
var parts = fSort.value.split('-');
var key = parts[0];
var dir = parts[1];
var visible = rows.filter(function(r) {
if (from && r.dataset.date < from) return false;
if (to && r.dataset.date > to) return false;
if (client && r.dataset.client !== client) return false;
return true;
});
visible.sort(function(a, b) {
var va, vb;
if (key === 'date') { va = a.dataset.date; vb = b.dataset.date; }
if (key === 'prix') { va = parseFloat(a.dataset.prix); vb = parseFloat(b.dataset.prix); }
if (key === 'poids') { va = parseFloat(a.dataset.poids); vb = parseFloat(b.dataset.poids); }
if (key === 'duree') { va = parseFloat(a.dataset.duree); vb = parseFloat(b.dataset.duree); }
if (dir === 'asc') return va > vb ? 1 : va < vb ? -1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
rows.forEach(function(r) { r.style.display = 'none'; });
visible.forEach(function(r) { r.style.display = ''; body.appendChild(r); });
noRes.classList.toggle('d-none', visible.length > 0);
var totalHT = 0, totalFinal = 0, totalPoids = 0;
visible.forEach(function(r) {
totalHT += parseFloat(r.dataset.prix) || 0;
totalFinal += parseFloat(r.dataset.final) || 0;
totalPoids += parseFloat(r.dataset.poids) || 0;
});
document.getElementById('sumCount').textContent = visible.length;
document.getElementById('sumHT').textContent = fmt(totalHT);
document.getElementById('sumFinal').textContent = fmt(totalFinal);
document.getElementById('sumPoids').textContent = fmtG(totalPoids);
}
fFrom.addEventListener('change', apply);
fTo.addEventListener('change', apply);
fCli.addEventListener('change', apply);
fSort.addEventListener('change', apply);
document.getElementById('fReset').addEventListener('click', function() {
fFrom.value = ''; fTo.value = ''; fCli.value = ''; fSort.value = 'date-desc';
apply();
});
apply();
})();
</script>
{% endblock %} {% endblock %}
+54 -2
View File
@@ -285,6 +285,41 @@
<label class="form-label fw-semibold">Notes internes</label> <label class="form-label fw-semibold">Notes internes</label>
<textarea name="notes" class="form-control" rows="2">{{ prefill.notes if prefill else '' }}</textarea> <textarea name="notes" class="form-control" rows="2">{{ prefill.notes if prefill else '' }}</textarea>
</div> </div>
<div class="mb-3 border rounded p-3" id="meshyBlock" style="background:#faf5ff">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="meshy_enabled" id="meshy_enabled"
value="1" onchange="onMeshyToggle()"
{% if prefill and prefill.meshy_enabled %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="meshy_enabled" style="color:#7c3aed">
<i class="bi bi-stars me-1"></i>Génération Meshy.ai
</label>
</div>
<div id="meshyCreditsRow" class="{% if not (prefill and prefill.meshy_enabled) %}d-none{% endif %}">
<div class="row g-2">
<div class="col-auto">
<label class="form-label small fw-semibold text-muted mb-1">Crédits utilisés</label>
<div class="input-group input-group-sm" style="max-width:180px">
<input type="number" name="meshy_credits_used" id="meshy_credits_used"
class="form-control" min="0" step="1"
value="{{ prefill.meshy_credits_used if prefill else 0 }}"
oninput="recalc()">
<span class="input-group-text">crédits</span>
</div>
</div>
<div class="col-auto">
<label class="form-label small fw-semibold text-muted mb-1">Marge Meshy (%)</label>
<div class="input-group input-group-sm" style="max-width:140px">
<input type="number" name="meshy_margin_pct" id="meshy_margin_pct"
class="form-control" min="0" max="500" step="1"
value="{{ prefill.meshy_margin_pct if prefill else settings.get('meshy_default_margin_pct', 30) }}"
oninput="recalc()">
<span class="input-group-text">%</span>
</div>
</div>
</div>
<div class="form-text mt-1" id="meshyCostHint"></div>
</div>
</div>
</div> </div>
</div> </div>
@@ -574,7 +609,7 @@ document.getElementById('materialSelect').addEventListener('change', function()
}); });
// ── Autres champs → recalc ──────────────────────────────────────────────────── // ── Autres champs → recalc ────────────────────────────────────────────────────
['weight_g','hours','minutes','discount_pct','pieces_per_plate','order_qty'].forEach(id => { ['weight_g','hours','minutes','discount_pct','pieces_per_plate','order_qty','meshy_credits_used','meshy_margin_pct'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener('input', recalc); if (el) el.addEventListener('input', recalc);
}); });
@@ -604,6 +639,9 @@ function recalc() {
material_profile_id: document.getElementById('materialProfileSelect').value || null, material_profile_id: document.getElementById('materialProfileSelect').value || null,
pieces_per_plate: parseInt(document.getElementById('pieces_per_plate').value) || 1, pieces_per_plate: parseInt(document.getElementById('pieces_per_plate').value) || 1,
order_qty: parseInt(document.getElementById('order_qty').value) || 1, order_qty: parseInt(document.getElementById('order_qty').value) || 1,
meshy_enabled: document.getElementById('meshy_enabled').checked,
meshy_credits_used: parseInt(document.getElementById('meshy_credits_used').value) || 0,
meshy_margin_pct: parseFloat(document.getElementById('meshy_margin_pct').value) || 0,
}; };
fetch('/api/calculate', { fetch('/api/calculate', {
@@ -612,7 +650,13 @@ function recalc() {
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
.then(r => r.json()) .then(r => r.json())
.then(d => renderBreakdown(d, body)) .then(d => {
renderBreakdown(d, body);
if (d.meshy_unit_cost && body.meshy_enabled) {
document.getElementById('meshyCostHint').innerHTML =
`<span class="text-muted">${d.meshy_unit_cost.toFixed(4)} €/crédit · coût brut : ${d.meshy_cost.toFixed(2)} € · marge : ${d.meshy_margin_amount.toFixed(2)} €</span>`;
}
})
.catch(() => {}); .catch(() => {});
} }
@@ -620,6 +664,12 @@ function recalc() {
function fmt(n) { return parseFloat(n).toFixed(4) + ' €'; } function fmt(n) { return parseFloat(n).toFixed(4) + ' €'; }
function fmt2(n) { return parseFloat(n).toFixed(2) + ' €'; } function fmt2(n) { return parseFloat(n).toFixed(2) + ' €'; }
function onMeshyToggle() {
const checked = document.getElementById('meshy_enabled').checked;
document.getElementById('meshyCreditsRow').classList.toggle('d-none', !checked);
recalc();
}
function renderBreakdown(d, body) { function renderBreakdown(d, body) {
const dm = parseFloat(body.design_multiplier).toFixed(2); const dm = parseFloat(body.design_multiplier).toFixed(2);
const mgn = parseFloat(body.gross_margin_pct).toFixed(1); const mgn = parseFloat(body.gross_margin_pct).toFixed(1);
@@ -724,11 +774,13 @@ function renderBreakdown(d, body) {
${row(`<span class="breakdown-muted ps-2">Usure — buse</span>`, d.nozzle_wear, '', true)} ${row(`<span class="breakdown-muted ps-2">Usure — buse</span>`, d.nozzle_wear, '', true)}
${row(`<span class="breakdown-muted ps-2">Usure — plateau</span>`, d.plate_wear, '', true)} ${row(`<span class="breakdown-muted ps-2">Usure — plateau</span>`, d.plate_wear, '', true)}
${row(`<span class="breakdown-muted ps-2">Electricite <small class="text-secondary">${parseFloat(d.kwh_used).toFixed(3)} kWh × ${d._elec_price} €</small></span>`, d.electricity_cost, '', true)} ${row(`<span class="breakdown-muted ps-2">Electricite <small class="text-secondary">${parseFloat(d.kwh_used).toFixed(3)} kWh × ${d._elec_price} €</small></span>`, d.electricity_cost, '', true)}
${body.meshy_enabled && d.meshy_cost > 0 ? row(`<span class="breakdown-muted ps-2" style="color:#7c3aed"><i class="bi bi-stars me-1"></i>Meshy.ai — coût abonnement (${body.meshy_credits_used} crédits)</span>`, d.meshy_cost, '', true) : ''}
${row(`<span class="fw-semibold">Total cout fixe</span>`, d.cout_fixe, 'subtotal')} ${row(`<span class="fw-semibold">Total cout fixe</span>`, d.cout_fixe, 'subtotal')}
<div class="breakdown-row section-header"><span>Partie variable</span></div> <div class="breakdown-row section-header"><span>Partie variable</span></div>
${row(`<span class="breakdown-muted ps-2">Manutention <small class="text-secondary">${d._handling_minutes} min × ${d._handling_rate} €/h</small></span>`, d.handling_cost, '', true)} ${row(`<span class="breakdown-muted ps-2">Manutention <small class="text-secondary">${d._handling_minutes} min × ${d._handling_rate} €/h</small></span>`, d.handling_cost, '', true)}
${row(`<span class="breakdown-muted ps-2">Design (x${dm})</span>`, d.design_cost, '', true)} ${row(`<span class="breakdown-muted ps-2">Design (x${dm})</span>`, d.design_cost, '', true)}
${body.meshy_enabled && d.meshy_margin_amount > 0 ? row(`<span class="breakdown-muted ps-2" style="color:#7c3aed"><i class="bi bi-stars me-1"></i>Marge Meshy.ai (${body.meshy_margin_pct}%)</span>`, d.meshy_margin_amount, '', true) : ''}
${row(`<span class="breakdown-muted ps-2">Marge brute (${mgn}%)</span>`, d.margin_amount)} ${row(`<span class="breakdown-muted ps-2">Marge brute (${mgn}%)</span>`, d.margin_amount)}
${row(`<span class="fw-semibold">Total marge <small class="text-muted fw-normal">(${d.marge_pct_on_ht}% du HT)</small></span>`, d.total_marge, 'subtotal')} ${row(`<span class="fw-semibold">Total marge <small class="text-muted fw-normal">(${d.marge_pct_on_ht}% du HT)</small></span>`, d.total_marge, 'subtotal')}
+39
View File
@@ -286,6 +286,45 @@
</div> </div>
</div> </div>
<!-- Meshy.ai -->
<div class="col-12">
<div class="card">
<div class="card-header py-3"><i class="bi bi-stars me-2" style="color:#7c3aed"></i>Meshy.ai — Génération 3D par IA</div>
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label fw-semibold">Coût abonnement annuel (€)</label>
<input type="number" name="meshy_annual_cost" class="form-control"
min="0" step="1"
value="{{ settings.get('meshy_annual_cost', 200)|int }}">
<div class="form-text">Montant total payé par an (ex: 200 € pour Meshy Pro).</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Crédits mensuels inclus</label>
<input type="number" name="meshy_monthly_credits" class="form-control"
min="1" step="1"
value="{{ settings.get('meshy_monthly_credits', 1000)|int }}">
<div class="form-text">Nb de crédits inclus chaque mois dans l'abonnement.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Multiplicateur de marge</label>
<input type="number" name="meshy_cost_multiplier" class="form-control"
min="1" step="0.1"
value="{{ settings.get('meshy_cost_multiplier', 1.0) }}">
<div class="form-text">
×1.0 = répercute le coût exact. ×1.5 = coût + 50% de marge sur Meshy.
<br>Coût unitaire actuel :
<strong>
{% set unit = settings.get('meshy_annual_cost', 200) / 12 / [settings.get('meshy_monthly_credits', 1000), 1]|max %}
{{ "%.4f"|format(unit) }} €/crédit
</strong>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- iCal --> <!-- iCal -->
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">