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:
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart' hide Order;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/types/order_type.dart';
|
||||
import '../../../domain/delivery/delivery.dart';
|
||||
@@ -19,6 +20,33 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
CheckoutFormBloc() : super(CheckoutFormState.initial()) {
|
||||
on<CheckoutFormEvent>(_onCheckoutFormEvent);
|
||||
}
|
||||
|
||||
/// Baris berbobot dihitung `weight × harga`, baris cacah `quantity × harga`.
|
||||
/// Satu tempat saja supaya total keranjang, subtotal, dan ringkasan tidak
|
||||
/// pernah berbeda hitungan.
|
||||
CheckoutFormState _recalculate(
|
||||
CheckoutFormState state,
|
||||
List<ProductQuantity> items,
|
||||
) {
|
||||
final totalQuantity = items.fold<int>(
|
||||
0,
|
||||
// Baris berbobot selalu satu penimbangan, jadi dihitung satu.
|
||||
(sum, item) => sum + (item.isWeighted ? 1 : item.quantity),
|
||||
);
|
||||
|
||||
final totalPrice = items.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + item.lineTotal,
|
||||
);
|
||||
|
||||
return state.copyWith(
|
||||
items: items,
|
||||
totalQuantity: totalQuantity,
|
||||
totalPrice: totalPrice.toInt(),
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCheckoutFormEvent(
|
||||
CheckoutFormEvent event,
|
||||
Emitter<CheckoutFormState> emit,
|
||||
@@ -26,12 +54,21 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
return event.map(
|
||||
addItem: (e) async {
|
||||
final currentState = state;
|
||||
|
||||
// Produk timbangan tidak punya stepper — masuk lewat addWeightedItem
|
||||
// setelah kasir memasukkan angka timbangan.
|
||||
if (e.product.isSoldByWeight) {
|
||||
log('⚖️ ${e.product.name} dijual per timbangan, butuh berat');
|
||||
return;
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(isLoading: true));
|
||||
|
||||
List<ProductQuantity> items = [...currentState.items];
|
||||
|
||||
final index = items.indexWhere(
|
||||
(element) =>
|
||||
!element.isWeighted &&
|
||||
element.product.id == e.product.id &&
|
||||
element.variant?.id == e.variant?.id,
|
||||
);
|
||||
@@ -49,35 +86,47 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
quantity: 1,
|
||||
variant: e.variant,
|
||||
notes: '',
|
||||
lineId: const Uuid().v4(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
log('🛒 Items updated: ${items.length} items total ${items.toList()}');
|
||||
|
||||
final totalQuantity = items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
emit(_recalculate(currentState, items));
|
||||
},
|
||||
addWeightedItem: (e) async {
|
||||
final currentState = state;
|
||||
emit(currentState.copyWith(isLoading: true));
|
||||
|
||||
final totalPrice = state.items.isEmpty
|
||||
? 0.0
|
||||
: state.items
|
||||
.map(
|
||||
(e) =>
|
||||
(e.product.price * e.quantity) +
|
||||
(e.variant?.priceModifier ?? 0),
|
||||
)
|
||||
.reduce((value, element) => value + element);
|
||||
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
items: items,
|
||||
totalQuantity: totalQuantity,
|
||||
totalPrice: totalPrice.toInt(),
|
||||
isLoading: false,
|
||||
// Sengaja tidak digabung dengan baris lain: dua penimbangan adalah dua
|
||||
// ikan berbeda, masing-masing bisa di-void atau dibayar terpisah.
|
||||
final items = [
|
||||
...currentState.items,
|
||||
ProductQuantity(
|
||||
product: e.product,
|
||||
variant: e.variant,
|
||||
quantity: 1,
|
||||
notes: '',
|
||||
lineId: const Uuid().v4(),
|
||||
weight: e.weight,
|
||||
),
|
||||
);
|
||||
];
|
||||
|
||||
log('⚖️ Penimbangan baru: ${e.product.name} ${e.weight}');
|
||||
|
||||
emit(_recalculate(currentState, items));
|
||||
},
|
||||
updateItemWeight: (e) async {
|
||||
final currentState = state;
|
||||
final items = [...currentState.items];
|
||||
|
||||
final index = items.indexWhere((item) => item.lineId == e.lineId);
|
||||
if (index == -1) return;
|
||||
|
||||
items[index] = items[index].copyWith(weight: e.weight);
|
||||
|
||||
emit(_recalculate(currentState, items));
|
||||
},
|
||||
removeItem: (e) async {
|
||||
final currentState = state;
|
||||
@@ -86,6 +135,7 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
|
||||
final index = items.indexWhere(
|
||||
(element) =>
|
||||
!element.isWeighted &&
|
||||
element.product.id == e.product.id &&
|
||||
element.variant?.id == e.variant?.id,
|
||||
);
|
||||
@@ -103,32 +153,18 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
}
|
||||
}
|
||||
|
||||
final totalQuantity = items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
final totalPrice = items.fold<int>(
|
||||
0,
|
||||
(sum, item) =>
|
||||
sum +
|
||||
(item.quantity *
|
||||
(item.variant?.priceModifier.toInt() ??
|
||||
item.product.price.toInt())),
|
||||
);
|
||||
log('🗑️ Item removed. Total items: ${items.length}');
|
||||
|
||||
log(
|
||||
'🗑️ Item removed. Total items: ${items.length}, totalQuantity: $totalQuantity, totalPrice: $totalPrice',
|
||||
);
|
||||
emit(_recalculate(currentState, items));
|
||||
},
|
||||
removeLine: (e) async {
|
||||
final currentState = state;
|
||||
final items = [...currentState.items]
|
||||
..removeWhere((item) => item.lineId == e.lineId);
|
||||
|
||||
// Emit state baru
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
items: items,
|
||||
totalQuantity: totalQuantity,
|
||||
totalPrice: totalPrice,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
log('🗑️ Baris dihapus. Sisa ${items.length} baris');
|
||||
|
||||
emit(_recalculate(currentState, items));
|
||||
},
|
||||
started: (e) async {
|
||||
emit(CheckoutFormState.initial().copyWith(isLoading: true));
|
||||
@@ -153,14 +189,13 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
);
|
||||
}
|
||||
},
|
||||
updateItemNotes: (e) async {
|
||||
updateLineNotes: (e) async {
|
||||
final currentState = state;
|
||||
|
||||
// Clone list items agar tidak mutasi langsung
|
||||
// Catatan diikat ke baris, bukan ke produk: dua penimbangan produk yang
|
||||
// sama bisa punya catatan berbeda.
|
||||
final items = [...currentState.items];
|
||||
final index = items.indexWhere(
|
||||
(element) => element.product.id == e.product.id,
|
||||
);
|
||||
final index = items.indexWhere((item) => item.lineId == e.lineId);
|
||||
|
||||
if (index != -1) {
|
||||
items[index] = items[index].copyWith(notes: e.notes);
|
||||
@@ -181,8 +216,8 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
|
||||
emit(state.copyWith(orderAdded: e.order));
|
||||
},
|
||||
customerNameChanged: (e) async {
|
||||
emit(state.copyWith(customerName: e.name,));
|
||||
}
|
||||
emit(state.copyWith(customerName: e.name));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,15 +10,32 @@ class CheckoutFormEvent with _$CheckoutFormEvent {
|
||||
ProductVariant? variant,
|
||||
) = _AddItem;
|
||||
|
||||
/// Satu penimbangan = satu baris baru. Dipanggil setiap kali kasir selesai
|
||||
/// memasukkan angka timbangan, meski produknya sama dengan baris lain.
|
||||
const factory CheckoutFormEvent.addWeightedItem(
|
||||
Product product,
|
||||
ProductVariant? variant,
|
||||
double weight,
|
||||
) = _AddWeightedItem;
|
||||
|
||||
/// Koreksi angka timbangan pada satu baris tertentu.
|
||||
const factory CheckoutFormEvent.updateItemWeight(
|
||||
String lineId,
|
||||
double weight,
|
||||
) = _UpdateItemWeight;
|
||||
|
||||
const factory CheckoutFormEvent.removeItem(
|
||||
Product product,
|
||||
ProductVariant? variant,
|
||||
) = _RemoveItem;
|
||||
|
||||
const factory CheckoutFormEvent.updateItemNotes(
|
||||
Product product,
|
||||
/// Hapus satu baris utuh — satu-satunya cara membatalkan baris berbobot.
|
||||
const factory CheckoutFormEvent.removeLine(String lineId) = _RemoveLine;
|
||||
|
||||
const factory CheckoutFormEvent.updateLineNotes(
|
||||
String lineId,
|
||||
String notes,
|
||||
) = _UpdateItemNotes;
|
||||
) = _UpdateLineNotes;
|
||||
|
||||
const factory CheckoutFormEvent.updateOrderType(OrderType orderType) =
|
||||
_UpdateOrderType;
|
||||
@@ -30,6 +47,6 @@ class CheckoutFormEvent with _$CheckoutFormEvent {
|
||||
|
||||
const factory CheckoutFormEvent.orderAddedItems(Order? order) =
|
||||
_OrderAddedItems;
|
||||
const factory CheckoutFormEvent.customerNameChanged(String name) =
|
||||
const factory CheckoutFormEvent.customerNameChanged(String name) =
|
||||
_CustomerNameChanged;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user