Compare commits
5 Commits
15ae5c5d77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a82dcc0c92 | |||
| 38fc4ed565 | |||
| c00704a2aa | |||
| 0d146e241d | |||
| 0619559190 |
+15
-1
@@ -61,6 +61,10 @@ Dernières migrations ajoutées (à la fin de la liste) :
|
|||||||
'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 marge_pct_on_ht REAL DEFAULT 0'
|
'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 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'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -99,6 +103,15 @@ Colonne `date_end` optionnelle. Requêtes avec `COALESCE(date_end, date)` pour c
|
|||||||
- À la création d'un job depuis Nextcloud, le path est sauvegardé dans un hidden input
|
- À 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
|
- 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
|
## Bugs résolus dans cette session
|
||||||
@@ -127,7 +140,7 @@ Colonne `date_end` optionnelle. Requêtes avec `COALESCE(date_end, date)` pour c
|
|||||||
### À surveiller
|
### À surveiller
|
||||||
- **jobs.html** : toujours utiliser le script Python pour modifier, vérifier la syntaxe Jinja2 après chaque modif avec `python3 -c "import jinja2; ..."`
|
- **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
|
- **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
|
- **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
|
### Fonctionnalité pas encore implémentée
|
||||||
- Recalcul de `marge_pct_on_ht` pour les jobs existants (valeur = 0 pour les anciens jobs)
|
- Recalcul de `marge_pct_on_ht` pour les jobs existants (valeur = 0 pour les anciens jobs)
|
||||||
@@ -171,6 +184,7 @@ git commit -m "description"
|
|||||||
|
|
||||||
Derniers commits :
|
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
|
3da19f5 feat: auto-reparse Nextcloud 3mf on job clone
|
||||||
0069ab2 fix: INSERT jobs missing ? for marge_pct_on_ht
|
0069ab2 fix: INSERT jobs missing ? for marge_pct_on_ht
|
||||||
80307ac feat: plate_name and total_marge in jobs list
|
80307ac feat: plate_name and total_marge in jobs list
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ def init_db():
|
|||||||
('ha_poll_interval','60'),
|
('ha_poll_interval','60'),
|
||||||
('meshy_annual_cost','200.0'),
|
('meshy_annual_cost','200.0'),
|
||||||
('meshy_monthly_credits','1000'),
|
('meshy_monthly_credits','1000'),
|
||||||
('meshy_cost_multiplier','1.0'),
|
('meshy_default_margin_pct','30.0'),
|
||||||
]
|
]
|
||||||
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
||||||
|
|
||||||
@@ -308,6 +308,8 @@ def init_db():
|
|||||||
'ALTER TABLE jobs ADD COLUMN meshy_enabled INTEGER DEFAULT 0',
|
'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_credits_used INTEGER DEFAULT 0',
|
||||||
'ALTER TABLE jobs ADD COLUMN meshy_cost REAL 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:
|
||||||
@@ -463,7 +465,7 @@ def parse_3mf(file_stream):
|
|||||||
|
|
||||||
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_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.
|
||||||
|
|
||||||
@@ -516,20 +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
|
||||||
|
|
||||||
# Coût Meshy.ai (génération IA) — pro-raté par crédits utilisés
|
# Coût Meshy.ai — coût brut dans cout_fixe, marge spécifique dans total_marge
|
||||||
meshy_unit = 0.0
|
meshy_unit = 0.0
|
||||||
meshy_billed = 0.0
|
meshy_raw = 0.0
|
||||||
|
meshy_margin_amount = 0.0
|
||||||
if meshy_enabled and meshy_credits_used > 0:
|
if meshy_enabled and meshy_credits_used > 0:
|
||||||
_meshy_annual = float(s.get('meshy_annual_cost', 200.0))
|
_meshy_annual = float(s.get('meshy_annual_cost', 200.0))
|
||||||
_meshy_credits = float(s.get('meshy_monthly_credits', 1000)) or 1
|
_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_unit = _meshy_annual / 12 / _meshy_credits
|
||||||
meshy_billed = meshy_credits_used * meshy_unit * _meshy_mult
|
meshy_raw = meshy_credits_used * meshy_unit
|
||||||
|
meshy_margin_amount = meshy_raw * (meshy_margin_pct / 100)
|
||||||
|
|
||||||
cout_fixe = mat + mat_margin + wear + elec
|
cout_fixe = mat + mat_margin + wear + elec + meshy_raw
|
||||||
sous_total = cout_fixe + handling + design + meshy_billed
|
sous_total = cout_fixe + handling + design
|
||||||
margin = sous_total * (gross_margin_pct / 100)
|
margin = sous_total * (gross_margin_pct / 100)
|
||||||
total_marge= handling + design + meshy_billed + 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
|
||||||
@@ -574,7 +577,8 @@ 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_billed, 4),
|
'meshy_cost': round(meshy_raw, 4),
|
||||||
|
'meshy_margin_amount': round(meshy_margin_amount, 4),
|
||||||
'meshy_unit_cost': round(meshy_unit, 6),
|
'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),
|
||||||
@@ -842,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()
|
||||||
@@ -860,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()
|
||||||
@@ -894,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')
|
||||||
@@ -1249,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():
|
||||||
@@ -1332,22 +1356,24 @@ def new_job():
|
|||||||
|
|
||||||
meshy_enabled_v = bool(request.form.get('meshy_enabled'))
|
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_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_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_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'],
|
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'],
|
||||||
@@ -1713,6 +1739,7 @@ def api_calculate():
|
|||||||
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_enabled = bool(d.get('meshy_enabled', False))
|
||||||
meshy_credits = max(0, int(d.get('meshy_credits_used', 0)))
|
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']),
|
||||||
@@ -1722,7 +1749,8 @@ def api_calculate():
|
|||||||
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_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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -15,11 +15,20 @@
|
|||||||
<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">
|
||||||
|
<div class="col-md-8">
|
||||||
<label class="form-label fw-semibold">Nom *</label>
|
<label class="form-label fw-semibold">Nom *</label>
|
||||||
<input type="text" name="name" class="form-control" required
|
<input type="text" name="name" class="form-control" required
|
||||||
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
|
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
|
||||||
</div>
|
</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 class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Email</label>
|
<label class="form-label fw-semibold">Email</label>
|
||||||
<input type="email" name="email" class="form-control"
|
<input type="email" name="email" class="form-control"
|
||||||
|
|||||||
@@ -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 & 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 %}
|
||||||
@@ -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,8 +176,8 @@
|
|||||||
<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 %}
|
{% if job.meshy_enabled and job.meshy_cost and job.meshy_margin_pct %}
|
||||||
{{ brow('Meshy.ai (' ~ job.meshy_credits_used ~ ' crédits)', job.meshy_cost, 'breakdown-muted ps-2', 4) }}
|
{{ brow('Marge Meshy.ai (' ~ job.meshy_margin_pct ~ '%)', job.meshy_cost * job.meshy_margin_pct / 100, 'breakdown-muted ps-2', 4) }}
|
||||||
{% endif %}
|
{% 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">
|
||||||
|
|||||||
+216
-26
@@ -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) }} €</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) }} € x{{ qty }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}--{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-end">
|
||||||
|
<span class="fw-semibold">{{ '%.2f'|format(prix_ttc) }} €</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) }} €</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) }} € TTC tot.
|
||||||
|
{% if j.discount_pct and j.discount_pct > 0 %}
|
||||||
|
→ <span style="color:#ff6b35">{{ '%.2f'|format((j.final_price or 0) * qty) }} €</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">→</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 %}
|
||||||
|
|||||||
+21
-5
@@ -295,6 +295,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="meshyCreditsRow" class="{% if not (prefill and prefill.meshy_enabled) %}d-none{% endif %}">
|
<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>
|
<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">
|
<div class="input-group input-group-sm" style="max-width:180px">
|
||||||
<input type="number" name="meshy_credits_used" id="meshy_credits_used"
|
<input type="number" name="meshy_credits_used" id="meshy_credits_used"
|
||||||
@@ -303,7 +305,19 @@
|
|||||||
oninput="recalc()">
|
oninput="recalc()">
|
||||||
<span class="input-group-text">crédits</span>
|
<span class="input-group-text">crédits</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text" id="meshyCostHint"></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>
|
||||||
@@ -595,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','meshy_credits_used'].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);
|
||||||
});
|
});
|
||||||
@@ -627,6 +641,7 @@ function recalc() {
|
|||||||
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_enabled: document.getElementById('meshy_enabled').checked,
|
||||||
meshy_credits_used: parseInt(document.getElementById('meshy_credits_used').value) || 0,
|
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', {
|
||||||
@@ -637,9 +652,9 @@ function recalc() {
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
renderBreakdown(d, body);
|
renderBreakdown(d, body);
|
||||||
if (d.meshy_unit_cost) {
|
if (d.meshy_unit_cost && body.meshy_enabled) {
|
||||||
document.getElementById('meshyCostHint').innerHTML =
|
document.getElementById('meshyCostHint').innerHTML =
|
||||||
`<span class="text-muted">Coût unitaire : ${d.meshy_unit_cost.toFixed(4)} €/crédit</span>`;
|
`<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(() => {});
|
||||||
@@ -759,12 +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_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) : ''}
|
${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')}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user