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
@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/constant/local_storage_key.dart';
import '../unit_dtos.dart';
/// Satuan berjumlah sedikit dan jarang berubah, jadi cukup disimpan sebagai
/// satu blob JSON di SharedPreferences — tidak perlu tabel sqflite sendiri.
@injectable
class UnitLocalDataProvider {
final SharedPreferences _sharedPreferences;
UnitLocalDataProvider(this._sharedPreferences);
Future<void> saveUnits(List<UnitDto> units) async {
final jsonString = jsonEncode(units.map((e) => e.toJson()).toList());
await _sharedPreferences.setString(LocalStorageKey.units, jsonString);
}
List<UnitDto> getUnits() {
final jsonString = _sharedPreferences.getString(LocalStorageKey.units);
if (jsonString == null || jsonString.isEmpty) return const [];
final decoded = jsonDecode(jsonString);
if (decoded is! List) return const [];
return decoded
.whereType<Map<String, dynamic>>()
.map(UnitDto.fromJson)
.toList();
}
Future<void> clearUnits() async {
await _sharedPreferences.remove(LocalStorageKey.units);
}
}
@@ -0,0 +1,37 @@
import 'dart:developer';
import 'package:data_channel/data_channel.dart';
import 'package:injectable/injectable.dart';
import '../../../common/api/api_client.dart';
import '../../../common/api/api_failure.dart';
import '../../../common/function/app_function.dart';
import '../../../common/url/api_path.dart';
import '../../../domain/unit/unit.dart';
import '../unit_dtos.dart';
@injectable
class UnitRemoteDataProvider {
final ApiClient _apiClient;
final _logName = 'UnitRemoteDataProvider';
UnitRemoteDataProvider(this._apiClient);
Future<DC<UnitFailure, List<UnitDto>>> fetchUnits() async {
try {
final response = await _apiClient.get(
ApiPath.units,
headers: getAuthorizationHeader(),
);
if (response.data['data'] == null) {
return DC.error(const UnitFailure.empty());
}
return DC.data(UnitDto.listFromResponse(response.data['data']));
} on ApiFailure catch (e, s) {
log('fetchUnitsError', name: _logName, error: e, stackTrace: s);
return DC.error(UnitFailure.serverError(e));
}
}
}