Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15ae5c5d77 | |||
| d4d51b205f |
@@ -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 |
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# 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 ""'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## 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** : 38 colonnes = 38 `?`. 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 :
|
||||
```
|
||||
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.
@@ -256,6 +256,9 @@ def init_db():
|
||||
('ha_url',''),
|
||||
('ha_token',''),
|
||||
('ha_poll_interval','60'),
|
||||
('meshy_annual_cost','200.0'),
|
||||
('meshy_monthly_credits','1000'),
|
||||
('meshy_cost_multiplier','1.0'),
|
||||
]
|
||||
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
||||
|
||||
@@ -302,6 +305,9 @@ def init_db():
|
||||
'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 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',
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
@@ -456,7 +462,8 @@ def parse_3mf(file_stream):
|
||||
return result
|
||||
|
||||
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):
|
||||
"""
|
||||
Moteur de pricing principal — calcule le prix de vente d'une pièce.
|
||||
|
||||
@@ -509,10 +516,20 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
|
||||
wear = printer_w + nozzle_w + plate_w
|
||||
elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh'] / pieces_per_plate
|
||||
|
||||
# Coût Meshy.ai (génération IA) — pro-raté par crédits utilisés
|
||||
meshy_unit = 0.0
|
||||
meshy_billed = 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_mult = float(s.get('meshy_cost_multiplier', 1.0))
|
||||
meshy_unit = _meshy_annual / 12 / _meshy_credits
|
||||
meshy_billed = meshy_credits_used * meshy_unit * _meshy_mult
|
||||
|
||||
cout_fixe = mat + mat_margin + wear + elec
|
||||
sous_total = cout_fixe + handling + design
|
||||
sous_total = cout_fixe + handling + design + meshy_billed
|
||||
margin = sous_total * (gross_margin_pct / 100)
|
||||
total_marge= handling + design + margin
|
||||
total_marge= handling + design + meshy_billed + margin
|
||||
prix_ht = cout_fixe + total_marge
|
||||
|
||||
cot_rate = s.get('cotisations_rate_pct', 12.3) / 100
|
||||
@@ -557,6 +574,8 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
|
||||
'tva_amount': round(tva_amount, 4),
|
||||
'prix_ht_net': round(prix_ht_net, 4),
|
||||
'prix_ttc': round(prix_ttc, 4),
|
||||
'meshy_cost': round(meshy_billed, 4),
|
||||
'meshy_unit_cost': round(meshy_unit, 6),
|
||||
'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4),
|
||||
'final_price': round(final, 2),
|
||||
'_cot_pct': round(cot_rate * 100, 2),
|
||||
@@ -1311,19 +1330,24 @@ def new_job():
|
||||
if mat:
|
||||
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))
|
||||
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)
|
||||
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,
|
||||
meshy_enabled,meshy_credits_used,meshy_cost,
|
||||
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
|
||||
pieces_per_plate,order_qty,
|
||||
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
|
||||
cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht,
|
||||
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
|
||||
price_per_piece,final_price,notes)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||||
(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',''),
|
||||
int(meshy_enabled_v),meshy_credits_v,r['meshy_cost'],
|
||||
weight_g,print_time_s,design_mult,gross_margin,discount,
|
||||
pieces_per_plate,order_qty,
|
||||
r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'],
|
||||
@@ -1687,6 +1711,8 @@ def api_calculate():
|
||||
pieces_per_plate = max(1, int(d.get('pieces_per_plate', 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
|
||||
meshy_enabled = bool(d.get('meshy_enabled', False))
|
||||
meshy_credits = max(0, int(d.get('meshy_credits_used', 0)))
|
||||
|
||||
result = calc(
|
||||
weight_g=float(d['weight_g']),
|
||||
@@ -1695,7 +1721,8 @@ def api_calculate():
|
||||
gross_margin_pct=float(d.get('gross_margin_pct',27.5)),
|
||||
discount_pct=float(d.get('discount_pct',0)),
|
||||
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)
|
||||
|
||||
# Multi-piece extras
|
||||
nb_plateaux = math.ceil(order_qty / pieces_per_plate)
|
||||
|
||||
@@ -172,6 +172,9 @@
|
||||
<div class="breakdown-row section-header"><span>Partie variable</span></div>
|
||||
{{ brow('Manutention', job.handling_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 %}
|
||||
{{ brow('Meshy.ai (' ~ job.meshy_credits_used ~ ' crédits)', job.meshy_cost, 'breakdown-muted ps-2', 4) }}
|
||||
{% endif %}
|
||||
{{ brow('Marge brute (' ~ job.gross_margin_pct ~ '%)', job.margin_amount, 'breakdown-muted ps-2') }}
|
||||
<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>
|
||||
|
||||
+38
-2
@@ -285,6 +285,27 @@
|
||||
<label class="form-label fw-semibold">Notes internes</label>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ prefill.notes if prefill else '' }}</textarea>
|
||||
</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 %}">
|
||||
<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 class="form-text" id="meshyCostHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -574,7 +595,7 @@ document.getElementById('materialSelect').addEventListener('change', function()
|
||||
});
|
||||
|
||||
// ── 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'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', recalc);
|
||||
});
|
||||
@@ -604,6 +625,8 @@ function recalc() {
|
||||
material_profile_id: document.getElementById('materialProfileSelect').value || null,
|
||||
pieces_per_plate: parseInt(document.getElementById('pieces_per_plate').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,
|
||||
};
|
||||
|
||||
fetch('/api/calculate', {
|
||||
@@ -612,7 +635,13 @@ function recalc() {
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => renderBreakdown(d, body))
|
||||
.then(d => {
|
||||
renderBreakdown(d, body);
|
||||
if (d.meshy_unit_cost) {
|
||||
document.getElementById('meshyCostHint').innerHTML =
|
||||
`<span class="text-muted">Coût unitaire : ${d.meshy_unit_cost.toFixed(4)} €/crédit</span>`;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -620,6 +649,12 @@ function recalc() {
|
||||
function fmt(n) { return parseFloat(n).toFixed(4) + ' €'; }
|
||||
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) {
|
||||
const dm = parseFloat(body.design_multiplier).toFixed(2);
|
||||
const mgn = parseFloat(body.gross_margin_pct).toFixed(1);
|
||||
@@ -729,6 +764,7 @@ function renderBreakdown(d, body) {
|
||||
<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">Design (x${dm})</span>`, d.design_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 (${body.meshy_credits_used} crédits)</span>`, d.meshy_cost, '', true) : ''}
|
||||
${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')}
|
||||
|
||||
|
||||
@@ -286,6 +286,45 @@
|
||||
</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 -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
|
||||
Reference in New Issue
Block a user