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));
}
}
}
@@ -0,0 +1,59 @@
part of '../unit_dtos.dart';
@freezed
class UnitDto with _$UnitDto {
const UnitDto._();
const factory UnitDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
@JsonKey(name: "is_active") bool? isActive,
}) = _UnitDto;
factory UnitDto.fromJson(Map<String, dynamic> json) =>
_$UnitDtoFromJson(json);
ProductUnit toDomain() => ProductUnit(
id: id ?? '',
organizationId: organizationId ?? '',
name: name ?? '',
abbreviation: abbreviation ?? '',
isActive: isActive ?? true,
);
factory UnitDto.fromDomain(ProductUnit unit) => UnitDto(
id: unit.id,
organizationId: unit.organizationId,
name: unit.name,
abbreviation: unit.abbreviation,
isActive: unit.isActive,
);
/// Response `/units` belum tentu berbentuk sama di setiap versi backend —
/// bisa `data: [...]` atau `data: { units: [...] }`. Terima keduanya supaya
/// POS tidak kehilangan satuan hanya karena pembungkusnya berbeda.
static List<UnitDto> listFromResponse(dynamic data) {
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(UnitDto.fromJson)
.toList();
}
if (data is Map<String, dynamic>) {
for (final key in const ['units', 'items', 'data']) {
final value = data[key];
if (value is List) {
return value
.whereType<Map<String, dynamic>>()
.map(UnitDto.fromJson)
.toList();
}
}
}
return const [];
}
}
@@ -0,0 +1,56 @@
import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/unit/unit.dart';
import '../datasources/local_data_provider.dart';
import '../datasources/remote_data_provider.dart';
@Injectable(as: IUnitRepository)
class UnitRepository implements IUnitRepository {
final UnitRemoteDataProvider _remoteDataProvider;
final UnitLocalDataProvider _localDataProvider;
final _logName = 'UnitRepository';
UnitRepository(this._remoteDataProvider, this._localDataProvider);
@override
Future<Either<UnitFailure, List<ProductUnit>>> getUnits({
bool forceRefresh = false,
}) async {
try {
final cached = _localDataProvider.getUnits();
if (!forceRefresh && cached.isNotEmpty) {
log('📏 ${cached.length} satuan dari cache', name: _logName);
return right(cached.map((e) => e.toDomain()).toList());
}
final result = await _remoteDataProvider.fetchUnits();
if (result.hasError) {
// Offline bukan alasan untuk kehilangan satuan yang sudah pernah ada.
if (cached.isNotEmpty) {
log('⚠️ Gagal ambil satuan, pakai cache', name: _logName);
return right(cached.map((e) => e.toDomain()).toList());
}
return left(result.error!);
}
final units = result.data!;
await _localDataProvider.saveUnits(units);
log('✅ ${units.length} satuan disinkronkan', name: _logName);
return right(units.map((e) => e.toDomain()).toList());
} catch (e, s) {
log('❌ Error getUnits', name: _logName, error: e, stackTrace: s);
return left(UnitFailure.localStorageError(e.toString()));
}
}
@override
Future<List<ProductUnit>> cachedUnits() async =>
_localDataProvider.getUnits().map((e) => e.toDomain()).toList();
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/unit/unit.dart';
part 'unit_dtos.freezed.dart';
part 'unit_dtos.g.dart';
part 'dtos/unit_dto.dart';
@@ -0,0 +1,280 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'unit_dtos.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
UnitDto _$UnitDtoFromJson(Map<String, dynamic> json) {
return _UnitDto.fromJson(json);
}
/// @nodoc
mixin _$UnitDto {
@JsonKey(name: "id")
String? get id => throw _privateConstructorUsedError;
@JsonKey(name: "organization_id")
String? get organizationId => throw _privateConstructorUsedError;
@JsonKey(name: "name")
String? get name => throw _privateConstructorUsedError;
@JsonKey(name: "abbreviation")
String? get abbreviation => throw _privateConstructorUsedError;
@JsonKey(name: "is_active")
bool? get isActive => throw _privateConstructorUsedError;
/// Serializes this UnitDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$UnitDtoCopyWith<UnitDto> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UnitDtoCopyWith<$Res> {
factory $UnitDtoCopyWith(UnitDto value, $Res Function(UnitDto) then) =
_$UnitDtoCopyWithImpl<$Res, UnitDto>;
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
@JsonKey(name: "is_active") bool? isActive,
});
}
/// @nodoc
class _$UnitDtoCopyWithImpl<$Res, $Val extends UnitDto>
implements $UnitDtoCopyWith<$Res> {
_$UnitDtoCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? organizationId = freezed,
Object? name = freezed,
Object? abbreviation = freezed,
Object? isActive = freezed,
}) {
return _then(
_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
organizationId: freezed == organizationId
? _value.organizationId
: organizationId // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
abbreviation: freezed == abbreviation
? _value.abbreviation
: abbreviation // ignore: cast_nullable_to_non_nullable
as String?,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$UnitDtoImplCopyWith<$Res> implements $UnitDtoCopyWith<$Res> {
factory _$$UnitDtoImplCopyWith(
_$UnitDtoImpl value,
$Res Function(_$UnitDtoImpl) then,
) = __$$UnitDtoImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
@JsonKey(name: "is_active") bool? isActive,
});
}
/// @nodoc
class __$$UnitDtoImplCopyWithImpl<$Res>
extends _$UnitDtoCopyWithImpl<$Res, _$UnitDtoImpl>
implements _$$UnitDtoImplCopyWith<$Res> {
__$$UnitDtoImplCopyWithImpl(
_$UnitDtoImpl _value,
$Res Function(_$UnitDtoImpl) _then,
) : super(_value, _then);
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? organizationId = freezed,
Object? name = freezed,
Object? abbreviation = freezed,
Object? isActive = freezed,
}) {
return _then(
_$UnitDtoImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
organizationId: freezed == organizationId
? _value.organizationId
: organizationId // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
abbreviation: freezed == abbreviation
? _value.abbreviation
: abbreviation // ignore: cast_nullable_to_non_nullable
as String?,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$UnitDtoImpl extends _UnitDto {
const _$UnitDtoImpl({
@JsonKey(name: "id") this.id,
@JsonKey(name: "organization_id") this.organizationId,
@JsonKey(name: "name") this.name,
@JsonKey(name: "abbreviation") this.abbreviation,
@JsonKey(name: "is_active") this.isActive,
}) : super._();
factory _$UnitDtoImpl.fromJson(Map<String, dynamic> json) =>
_$$UnitDtoImplFromJson(json);
@override
@JsonKey(name: "id")
final String? id;
@override
@JsonKey(name: "organization_id")
final String? organizationId;
@override
@JsonKey(name: "name")
final String? name;
@override
@JsonKey(name: "abbreviation")
final String? abbreviation;
@override
@JsonKey(name: "is_active")
final bool? isActive;
@override
String toString() {
return 'UnitDto(id: $id, organizationId: $organizationId, name: $name, abbreviation: $abbreviation, isActive: $isActive)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UnitDtoImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.organizationId, organizationId) ||
other.organizationId == organizationId) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.abbreviation, abbreviation) ||
other.abbreviation == abbreviation) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
organizationId,
name,
abbreviation,
isActive,
);
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$UnitDtoImplCopyWith<_$UnitDtoImpl> get copyWith =>
__$$UnitDtoImplCopyWithImpl<_$UnitDtoImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UnitDtoImplToJson(this);
}
}
abstract class _UnitDto extends UnitDto {
const factory _UnitDto({
@JsonKey(name: "id") final String? id,
@JsonKey(name: "organization_id") final String? organizationId,
@JsonKey(name: "name") final String? name,
@JsonKey(name: "abbreviation") final String? abbreviation,
@JsonKey(name: "is_active") final bool? isActive,
}) = _$UnitDtoImpl;
const _UnitDto._() : super._();
factory _UnitDto.fromJson(Map<String, dynamic> json) = _$UnitDtoImpl.fromJson;
@override
@JsonKey(name: "id")
String? get id;
@override
@JsonKey(name: "organization_id")
String? get organizationId;
@override
@JsonKey(name: "name")
String? get name;
@override
@JsonKey(name: "abbreviation")
String? get abbreviation;
@override
@JsonKey(name: "is_active")
bool? get isActive;
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$UnitDtoImplCopyWith<_$UnitDtoImpl> get copyWith =>
throw _privateConstructorUsedError;
}
+25
View File
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'unit_dtos.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UnitDtoImpl _$$UnitDtoImplFromJson(Map<String, dynamic> json) =>
_$UnitDtoImpl(
id: json['id'] as String?,
organizationId: json['organization_id'] as String?,
name: json['name'] as String?,
abbreviation: json['abbreviation'] as String?,
isActive: json['is_active'] as bool?,
);
Map<String, dynamic> _$$UnitDtoImplToJson(_$UnitDtoImpl instance) =>
<String, dynamic>{
'id': instance.id,
'organization_id': instance.organizationId,
'name': instance.name,
'abbreviation': instance.abbreviation,
'is_active': instance.isActive,
};