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: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;
}