Version initiale de Gestion Prix

App Flutter de gestion de stock/prix pour tablette Android (100% local) :
scan code-barres (OpenFoodFacts), prix au kilo/litre, packs, inventaire,
prix d'achat + marge, filtres de tri, étiquettes PDF, design noir & blanc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:28:33 +02:00
commit 4d936d2ee3
53 changed files with 3852 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
/// Unités de vente supportées (poids et volume).
class Unites {
static const g = 'g';
static const kg = 'kg';
static const ml = 'ml';
static const cl = 'cl';
static const l = 'L';
/// Ancienne valeur (produits d'avant) : vendu à l'unité. Plus proposée.
static const piece = 'piece';
static const tout = [g, kg, ml, cl, l];
/// Vrai si le code est une unité de mesure valide (donc pas 'piece'/null).
static bool estValide(String? code) => tout.contains(code);
static String libelle(String? code) => switch (code) {
g => 'g',
kg => 'kg',
ml => 'ml',
cl => 'cl',
l => 'L',
_ => '',
};
}
/// Un produit du stock.
class Produit {
final String id;
final String? codeBarres;
final String nom;
final String? imageUrl;
/// Prix de vente (au client).
final double prix;
/// Prix d'achat (coût). 0 = non renseigné.
final double prixAchat;
/// Contenance d'UN contenant (ex : 330 pour 330 ml, 400 pour 400 g).
final double? quantite;
/// Unité de la quantité : voir [Unites].
final String? unite;
/// Nombre de contenants pour un pack (ex : 6 pour un pack de 6 bières).
/// Null ou 1 = produit à l'unité (pas un pack).
final int? nbContenants;
/// Horodatage de création (ms) — sert au tri par ordre d'ajout.
final int creeLe;
/// Quantité en stock (inventaire).
final int stock;
const Produit({
required this.id,
this.codeBarres,
required this.nom,
this.imageUrl,
required this.prix,
this.prixAchat = 0,
this.quantite,
this.unite,
this.nbContenants,
this.creeLe = 0,
this.stock = 0,
});
bool get estPack => (nbContenants ?? 1) > 1;
/// Gain en euros (vente achat). Pertinent seulement si un prix d'achat est saisi.
double get gain => prix - prixAchat;
/// Coefficient multiplicateur (prix de vente ÷ prix d'achat), ex : 1,5.
/// Null si le prix d'achat n'est pas renseigné. Sert au tri.
double? get coefficient => prixAchat > 0 ? prix / prixAchat : null;
/// Marge affichée : le coefficient (1,5) suivi d'un « % », ex : « 1,5 % », « 2 % ».
/// Vide si le prix d'achat n'est pas renseigné.
String get margeLibelle {
final c = coefficient;
if (c == null) return '';
var s = c.toStringAsFixed(2).replaceAll('.', ',');
if (s.contains(',')) {
s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r',$'), '');
}
return '$s %';
}
Produit copyWith({
String? id,
String? codeBarres,
String? nom,
String? imageUrl,
double? prix,
double? prixAchat,
double? quantite,
String? unite,
int? nbContenants,
int? creeLe,
int? stock,
}) {
return Produit(
id: id ?? this.id,
codeBarres: codeBarres ?? this.codeBarres,
nom: nom ?? this.nom,
imageUrl: imageUrl ?? this.imageUrl,
prix: prix ?? this.prix,
prixAchat: prixAchat ?? this.prixAchat,
quantite: quantite ?? this.quantite,
unite: unite ?? this.unite,
nbContenants: nbContenants ?? this.nbContenants,
creeLe: creeLe ?? this.creeLe,
stock: stock ?? this.stock,
);
}
/// Prix à l'unité de mesure (€/kg ou €/L) — obligation légale pour les
/// produits vendus au poids/volume. Null pour les produits à la pièce.
({double valeur, String suffixe})? get prixMesure {
final q = quantite;
if (q == null || q <= 0) return null;
// Pour un pack, on calcule sur le volume/poids TOTAL (contenance × nombre).
final total = q * (nbContenants ?? 1);
return switch (unite) {
Unites.g => (valeur: prix / (total / 1000), suffixe: '/kg'),
Unites.kg => (valeur: prix / total, suffixe: '/kg'),
Unites.ml => (valeur: prix / (total / 1000), suffixe: '/L'),
Unites.cl => (valeur: prix / (total / 100), suffixe: '/L'),
Unites.l => (valeur: prix / total, suffixe: '/L'),
_ => null,
};
}
/// Libellé compact de la quantité, ex : « 330ml », « 1kg », « 75cl »,
/// ou « 6x33cl » pour un pack. Vide si absent.
String get quantiteLibelle {
final q = quantite;
if (q == null || q <= 0 || !Unites.estValide(unite)) return '';
final n = q == q.roundToDouble()
? q.toInt().toString()
: q.toString().replaceAll('.', ',');
final base = '$n${Unites.libelle(unite)}';
return estPack ? '${nbContenants}X$base' : base;
}
/// Titre affiché (liste + étiquette) : la quantité est intégrée devant le nom,
/// ex : « 1kg Farine », « 75cl Coca-Cola ». Sinon juste le nom.
String get titre {
final ql = quantiteLibelle;
return ql.isEmpty ? nom : '$ql $nom';
}
factory Produit.fromMap(Map<String, dynamic> map) {
return Produit(
id: map['id'].toString(),
codeBarres: map['code_barres'] as String?,
nom: (map['nom'] as String?) ?? '',
imageUrl: map['image_url'] as String?,
prix: (map['prix'] as num?)?.toDouble() ?? 0,
prixAchat: (map['prix_achat'] as num?)?.toDouble() ?? 0,
quantite: (map['quantite'] as num?)?.toDouble(),
unite: map['unite'] as String?,
nbContenants: (map['nb_contenants'] as num?)?.toInt(),
creeLe: (map['cree_le'] as num?)?.toInt() ?? 0,
stock: (map['stock'] as num?)?.toInt() ?? 0,
);
}
/// Map pour insertion/mise à jour dans la base (sans l'id, généré par SQLite).
Map<String, dynamic> toInsertMap() {
return {
'code_barres': codeBarres,
'nom': nom,
'image_url': imageUrl,
'prix': prix,
'prix_achat': prixAchat,
'quantite': quantite,
'unite': unite,
'nb_contenants': nbContenants,
'stock': stock,
};
}
}