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:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user