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,139 @@
|
||||
import 'package:apskel_pos_flutter_v2/application/checkout/checkout_form/checkout_form_bloc.dart';
|
||||
import 'package:apskel_pos_flutter_v2/common/types/sell_by.dart';
|
||||
import 'package:apskel_pos_flutter_v2/domain/order/order.dart';
|
||||
import 'package:apskel_pos_flutter_v2/domain/product/product.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Ikan Tude — Rp 4.500 per ons.
|
||||
final ikanTude = Product.empty().copyWith(
|
||||
id: 'ikan-tude',
|
||||
name: 'Ikan Tude',
|
||||
price: 4500,
|
||||
isActive: true,
|
||||
sellBy: SellBy.weight,
|
||||
unitId: 'unit-ons',
|
||||
unitName: 'Ons',
|
||||
unitAbbreviation: 'ons',
|
||||
);
|
||||
|
||||
final nasiGoreng = Product.empty().copyWith(
|
||||
id: 'nasi-goreng',
|
||||
name: 'Nasi Goreng',
|
||||
price: 25000,
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('keranjang produk timbangan', () {
|
||||
test('dua penimbangan produk yang sama menjadi dua baris', () async {
|
||||
final bloc = CheckoutFormBloc();
|
||||
|
||||
bloc.add(CheckoutFormEvent.addWeightedItem(ikanTude, null, 4.2));
|
||||
bloc.add(CheckoutFormEvent.addWeightedItem(ikanTude, null, 5.6));
|
||||
await bloc.stream.firstWhere((s) => s.items.length == 2);
|
||||
|
||||
final items = bloc.state.items;
|
||||
expect(items.length, 2);
|
||||
expect(items.map((e) => e.weight), [4.2, 5.6]);
|
||||
expect(items.every((e) => e.quantity == 1), isTrue);
|
||||
expect(items[0].lineId, isNot(items[1].lineId));
|
||||
});
|
||||
|
||||
test('produk cacah tetap digabung jadi satu baris', () async {
|
||||
final bloc = CheckoutFormBloc();
|
||||
|
||||
bloc.add(CheckoutFormEvent.addItem(nasiGoreng, null));
|
||||
bloc.add(CheckoutFormEvent.addItem(nasiGoreng, null));
|
||||
await bloc.stream.firstWhere((s) => s.items.isNotEmpty && s.items.first.quantity == 2);
|
||||
|
||||
expect(bloc.state.items.length, 1);
|
||||
expect(bloc.state.items.first.quantity, 2);
|
||||
});
|
||||
|
||||
test('produk timbangan tidak bisa masuk tanpa berat', () async {
|
||||
final bloc = CheckoutFormBloc();
|
||||
|
||||
bloc.add(CheckoutFormEvent.addItem(ikanTude, null));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(bloc.state.items, isEmpty);
|
||||
});
|
||||
|
||||
test('total baris dihitung berat x harga satuan', () {
|
||||
final line = ProductQuantity(
|
||||
product: ikanTude,
|
||||
quantity: 1,
|
||||
notes: '',
|
||||
weight: 4.2,
|
||||
);
|
||||
|
||||
expect(line.lineTotal, closeTo(18900, 0.001));
|
||||
expect(line.quantityLabel, '4,2 ons');
|
||||
});
|
||||
|
||||
test('total keranjang mencampur baris berbobot dan baris cacah', () {
|
||||
final items = [
|
||||
ProductQuantity(
|
||||
product: ikanTude,
|
||||
quantity: 1,
|
||||
notes: '',
|
||||
weight: 4.2,
|
||||
),
|
||||
ProductQuantity(product: nasiGoreng, quantity: 2, notes: ''),
|
||||
];
|
||||
|
||||
expect(items.cartTotal, closeTo(18900 + 50000, 0.001));
|
||||
expect(items.cartItemCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('permintaan ke backend', () {
|
||||
test('baris berbobot dikirim quantity 1 dan membawa berat', () {
|
||||
final request = OrderItemRequest.fromProductQuantity(
|
||||
ProductQuantity(
|
||||
product: ikanTude,
|
||||
quantity: 1,
|
||||
notes: '',
|
||||
weight: 4.2,
|
||||
),
|
||||
);
|
||||
|
||||
expect(request.quantity, 1);
|
||||
expect(request.weight, 4.2);
|
||||
expect(request.unitPrice, 4500);
|
||||
});
|
||||
|
||||
test('produk cacah tidak membawa berat sama sekali', () {
|
||||
final request = OrderItemRequest.fromProductQuantity(
|
||||
ProductQuantity(product: nasiGoreng, quantity: 2, notes: ''),
|
||||
);
|
||||
|
||||
expect(request.quantity, 2);
|
||||
expect(request.weight, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('tampilan baris pesanan', () {
|
||||
test('baris berbobot memakai berat, bukan quantity', () {
|
||||
final item = OrderItem.empty().copyWith(
|
||||
quantity: 1,
|
||||
unitPrice: 4500,
|
||||
totalPrice: 18900,
|
||||
weight: 4.2,
|
||||
unitAbbreviation: 'ons',
|
||||
);
|
||||
|
||||
expect(item.isWeighted, isTrue);
|
||||
expect(item.quantityLabel, '4,2 ons');
|
||||
expect(item.quantityPriceLabel, '4,2 ons x Rp 4.500');
|
||||
});
|
||||
|
||||
test('baris biasa tetap memakai quantity', () {
|
||||
final item = OrderItem.empty().copyWith(quantity: 2, unitPrice: 25000);
|
||||
|
||||
expect(item.isWeighted, isFalse);
|
||||
expect(item.quantityLabel, '2');
|
||||
expect(item.quantityPriceLabel, '2 x Rp 25.000');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user