feat: dukung penjualan produk per timbangan

Produk kini punya cara jual `unit` (cacah) atau `weight` (timbangan),
mengikuti kontrak backend di docs/integration-weight-based-products.md.

- tipe SellBy, domain/infrastructure unit baru, dan endpoint /api/v1/units
- dialog input berat saat produk timbangan ditambahkan ke keranjang
- tiap penimbangan jadi baris keranjang sendiri; total baris dihitung
  berat x harga satuan, sedangkan produk cacah tetap digabung
- request ke backend mengirim quantity 1 dan membawa berat
- migrasi DB v3: kolom sell_by, unit_id, unit_name, unit_abbreviation
  di tabel products supaya satuan tetap tampil saat offline
- tampilan keranjang, struk, split bill, void, dan refund memakai berat
- test/weight_based_products_test.dart menutup perilaku di atas (9 test)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-06 23:03:31 +07:00
co-authored by Claude Opus 5
parent 9777f63426
commit 1505a83dd3
72 changed files with 5388 additions and 584 deletions
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../common/service/unit_registry.dart';
import '../../../domain/product/product.dart';
import '../datasources/local_data_provider.dart';
import '../datasources/remote_data_provider.dart';
@@ -12,10 +13,38 @@ import '../product_dtos.dart';
class ProductRepository implements IProductRepository {
final ProductRemoteDataProvider _remoteDataProvider;
final ProductLocalDataProvider _localDataProvider;
final UnitRegistry _unitRegistry;
final _logName = 'ProductRepository';
ProductRepository(this._remoteDataProvider, this._localDataProvider);
ProductRepository(
this._remoteDataProvider,
this._localDataProvider,
this._unitRegistry,
);
/// Response produk hanya dijamin membawa `unit_id`. Lengkapi nama dan
/// singkatannya dari master satuan supaya keranjang bisa menulis "4,2 ons".
Product _withUnit(Product product) {
if (!product.isSoldByWeight || product.unitId.isEmpty) return product;
if (product.unitAbbreviation.isNotEmpty && product.unitName.isNotEmpty) {
return product;
}
final unit = _unitRegistry.byId(product.unitId);
if (unit == null) return product;
return product.copyWith(
unitName: product.unitName.isNotEmpty ? product.unitName : unit.name,
unitAbbreviation: product.unitAbbreviation.isNotEmpty
? product.unitAbbreviation
: unit.abbreviation,
);
}
List<Product> _withUnits(List<Product> products) =>
products.map(_withUnit).toList();
@override
Future<Either<ProductFailure, Product>> getProductById(String id) async {
@@ -33,7 +62,7 @@ class ProductRepository implements IProductRepository {
);
}
final productDomain = product.data!.toDomain();
final productDomain = _withUnit(product.data!.toDomain());
log('✅ Product loaded: ${productDomain.name}', name: _logName);
return Right(productDomain);
@@ -85,7 +114,9 @@ class ProductRepository implements IProductRepository {
// 🧱 Bangun entity domain ListProduct
final result = ListProduct(
products: cachedProducts.data!.map((p) => p.toDomain()).toList(),
products: _withUnits(
cachedProducts.data!.map((p) => p.toDomain()).toList(),
),
totalCount: totalCount,
page: page,
limit: limit,
@@ -132,7 +163,9 @@ class ProductRepository implements IProductRepository {
return left(cachedProducts.error!);
}
final products = cachedProducts.data!.map((p) => p.toDomain()).toList();
final products = _withUnits(
cachedProducts.data!.map((p) => p.toDomain()).toList(),
);
final totalCount = await _localDataProvider.getTotalCount(
categoryId: categoryId,
@@ -180,7 +213,9 @@ class ProductRepository implements IProductRepository {
}
// ✅ Mapping ke domain entity (kalau hasilnya masih berupa DTO)
final products = results.data!.map((p) => p.toDomain()).toList();
final products = _withUnits(
results.data!.map((p) => p.toDomain()).toList(),
);
log(
'✅ Local search completed: ${products.length} results',
@@ -369,7 +404,7 @@ class ProductRepository implements IProductRepository {
final products = result.data!.toDomain();
return right(products);
return right(products.copyWith(products: _withUnits(products.products)));
} catch (e, s) {
log('getProducts', name: _logName, error: e, stackTrace: s);
return left(const ProductFailure.unexpectedError());