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>
320 lines
8.8 KiB
Dart
320 lines
8.8 KiB
Dart
part of '../order.dart';
|
|
|
|
@freezed
|
|
class ListOrder with _$ListOrder {
|
|
const factory ListOrder({
|
|
required List<Order> orders,
|
|
required int totalCount,
|
|
required int page,
|
|
required int limit,
|
|
required int totalPages,
|
|
}) = _ListOrder;
|
|
|
|
factory ListOrder.empty() => const ListOrder(
|
|
orders: [],
|
|
totalCount: 0,
|
|
page: 0,
|
|
limit: 0,
|
|
totalPages: 0,
|
|
);
|
|
}
|
|
|
|
@freezed
|
|
class Order with _$Order {
|
|
const Order._();
|
|
|
|
const factory Order({
|
|
required String id,
|
|
required String orderNumber,
|
|
required String outletId,
|
|
required String userId,
|
|
required String tableNumber,
|
|
required String orderType,
|
|
required String status,
|
|
required int subtotal,
|
|
required int taxAmount,
|
|
required int discountAmount,
|
|
required int totalAmount,
|
|
required num totalCost,
|
|
required int remainingAmount,
|
|
required String paymentStatus,
|
|
required int refundAmount,
|
|
required String refundReason,
|
|
required bool isVoid,
|
|
required bool isRefund,
|
|
required String notes,
|
|
required Map<String, dynamic> metadata,
|
|
required DateTime createdAt,
|
|
required DateTime updatedAt,
|
|
required List<OrderItem> orderItems,
|
|
required List<Payment> payments,
|
|
required int totalPaid,
|
|
required int paymentCount,
|
|
required String splitType,
|
|
}) = _Order;
|
|
|
|
factory Order.empty() => Order(
|
|
id: '',
|
|
orderNumber: '',
|
|
outletId: '',
|
|
userId: '',
|
|
tableNumber: '',
|
|
orderType: '',
|
|
status: '',
|
|
subtotal: 0,
|
|
taxAmount: 0,
|
|
discountAmount: 0,
|
|
totalAmount: 0,
|
|
totalCost: 0,
|
|
remainingAmount: 0,
|
|
paymentStatus: '',
|
|
refundAmount: 0,
|
|
isVoid: false,
|
|
isRefund: false,
|
|
notes: '',
|
|
metadata: const {},
|
|
createdAt: DateTime(1970),
|
|
updatedAt: DateTime(1970),
|
|
orderItems: const [],
|
|
payments: const [],
|
|
totalPaid: 0,
|
|
paymentCount: 0,
|
|
splitType: '',
|
|
refundReason: '',
|
|
);
|
|
|
|
factory Order.mockOrder() => Order(
|
|
id: 'ORD-001',
|
|
orderNumber: 'A-1001',
|
|
outletId: 'OUTLET-001',
|
|
userId: 'USER-001',
|
|
tableNumber: 'T01',
|
|
orderType: 'Dine In',
|
|
status: 'Completed',
|
|
subtotal: 150000,
|
|
taxAmount: 15000,
|
|
discountAmount: 5000,
|
|
totalAmount: 160000,
|
|
totalCost: 100000,
|
|
remainingAmount: 0,
|
|
paymentStatus: 'Paid',
|
|
refundAmount: 0,
|
|
refundReason: '',
|
|
isVoid: false,
|
|
isRefund: false,
|
|
notes: 'Customer requested less sugar',
|
|
metadata: {'source': 'POS', 'device': 'iPad', 'customer_name': "John Doe"},
|
|
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
|
|
updatedAt: DateTime.now(),
|
|
orderItems: [
|
|
OrderItem(
|
|
id: 'ITEM-001',
|
|
orderId: 'ORD-001',
|
|
productId: 'PROD-001',
|
|
productName: 'Cappuccino',
|
|
productVariantId: 'VAR-001',
|
|
productVariantName: 'Hot',
|
|
quantity: 2,
|
|
unitPrice: 25000,
|
|
totalPrice: 50000,
|
|
modifiers: [
|
|
{'name': 'Extra Shot', 'price': 5000},
|
|
],
|
|
notes: 'No sugar',
|
|
status: 'Served',
|
|
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
|
|
updatedAt: DateTime.now(),
|
|
printerType: 'Barista',
|
|
printToChecker: false,
|
|
paidQuantity: 2,
|
|
categoryId: 'CAT-001',
|
|
categoryName: 'Minuman',
|
|
),
|
|
OrderItem(
|
|
id: 'ITEM-002',
|
|
orderId: 'ORD-001',
|
|
productId: 'PROD-002',
|
|
productName: 'Spaghetti Carbonara',
|
|
productVariantId: 'VAR-002',
|
|
productVariantName: '',
|
|
quantity: 1,
|
|
unitPrice: 55000,
|
|
totalPrice: 55000,
|
|
modifiers: [],
|
|
notes: '',
|
|
status: 'Served',
|
|
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
|
|
updatedAt: DateTime.now(),
|
|
printerType: 'Kitchen',
|
|
printToChecker: false,
|
|
paidQuantity: 1,
|
|
categoryId: 'CAT-002',
|
|
categoryName: 'Makanan',
|
|
),
|
|
OrderItem(
|
|
id: 'ITEM-003',
|
|
orderId: 'ORD-001',
|
|
productId: 'PROD-003',
|
|
productName: 'Pasta',
|
|
productVariantId: 'VAR-002',
|
|
productVariantName: '',
|
|
quantity: 1,
|
|
unitPrice: 50000,
|
|
totalPrice: 50000,
|
|
modifiers: [],
|
|
notes: '',
|
|
status: 'Served',
|
|
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
|
|
updatedAt: DateTime.now(),
|
|
printerType: 'Kitchen',
|
|
printToChecker: false,
|
|
paidQuantity: 1,
|
|
categoryId: 'CAT-002',
|
|
categoryName: 'Makanan',
|
|
),
|
|
],
|
|
payments: [
|
|
Payment(
|
|
id: 'PAY-001',
|
|
orderId: 'ORD-001',
|
|
paymentMethodId: 'PM-CASH',
|
|
paymentMethodName: 'Cash',
|
|
paymentMethodType: 'Cash',
|
|
amount: 160000,
|
|
status: 'Success',
|
|
splitNumber: 1,
|
|
splitTotal: 1,
|
|
splitDescription: 'Full payment by cash',
|
|
refundAmount: 0,
|
|
metadata: {'cashier': 'Efril', 'device': 'POS iPad'},
|
|
createdAt: DateTime.now().subtract(const Duration(minutes: 45)),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
],
|
|
totalPaid: 160000,
|
|
paymentCount: 1,
|
|
splitType: 'Single',
|
|
);
|
|
|
|
// For Add Item Order
|
|
factory Order.fromLastOrder(Order order, List<ProductQuantity> products) {
|
|
final subtotal = products.fold<int>(
|
|
0,
|
|
(sum, item) => sum + item.lineTotal.toInt(),
|
|
);
|
|
|
|
return order.copyWith(
|
|
orderItems: products.map(OrderItem.fromProductQuantity).toList(),
|
|
totalAmount: subtotal,
|
|
subtotal: subtotal,
|
|
metadata: order.metadata,
|
|
);
|
|
}
|
|
|
|
List<OrderItem> get orderItemNotCancelled =>
|
|
orderItems.where((item) => item.status != 'cancelled').toList();
|
|
}
|
|
|
|
@freezed
|
|
class OrderItem with _$OrderItem {
|
|
const OrderItem._();
|
|
|
|
const factory OrderItem({
|
|
required String id,
|
|
required String orderId,
|
|
required String productId,
|
|
required String productName,
|
|
required String productVariantId,
|
|
required String productVariantName,
|
|
required int quantity,
|
|
required int unitPrice,
|
|
required int totalPrice,
|
|
required List<dynamic> modifiers,
|
|
required String notes,
|
|
required String status,
|
|
required DateTime createdAt,
|
|
required DateTime updatedAt,
|
|
required String printerType,
|
|
required bool printToChecker,
|
|
required int paidQuantity,
|
|
required String categoryId,
|
|
required String categoryName,
|
|
|
|
/// Angka timbangan baris ini. `null` untuk produk cacah.
|
|
double? weight,
|
|
@Default('') String unitId,
|
|
@Default('') String unitName,
|
|
@Default('') String unitAbbreviation,
|
|
}) = _OrderItem;
|
|
|
|
factory OrderItem.empty() => OrderItem(
|
|
id: '',
|
|
orderId: '',
|
|
productId: '',
|
|
productName: '',
|
|
productVariantId: '',
|
|
productVariantName: '',
|
|
quantity: 0,
|
|
unitPrice: 0,
|
|
totalPrice: 0,
|
|
modifiers: const [],
|
|
notes: '',
|
|
status: '',
|
|
createdAt: DateTime(1970),
|
|
updatedAt: DateTime(1970),
|
|
printerType: '',
|
|
printToChecker: false,
|
|
paidQuantity: 0,
|
|
categoryId: '',
|
|
categoryName: '',
|
|
);
|
|
|
|
factory OrderItem.fromProductQuantity(ProductQuantity productQuantity) =>
|
|
OrderItem(
|
|
id: '',
|
|
orderId: '',
|
|
productId: productQuantity.product.id,
|
|
productName: productQuantity.product.name,
|
|
productVariantId: productQuantity.variant?.id ?? '',
|
|
productVariantName: productQuantity.variant?.name ?? '',
|
|
quantity: productQuantity.isWeighted ? 1 : productQuantity.quantity,
|
|
unitPrice: productQuantity.product.price.toInt(),
|
|
totalPrice: productQuantity.lineTotal.toInt(),
|
|
modifiers: [],
|
|
notes: productQuantity.notes,
|
|
status: 'pending',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
printerType: productQuantity.product.printerType,
|
|
printToChecker: productQuantity.product.printToChecker,
|
|
paidQuantity: 0,
|
|
categoryId: '',
|
|
categoryName: '',
|
|
weight: productQuantity.weight,
|
|
unitId: productQuantity.product.unitId,
|
|
unitName: productQuantity.product.unitName,
|
|
unitAbbreviation: productQuantity.product.unitAbbreviation,
|
|
);
|
|
|
|
/// Baris hasil penimbangan — utuh, tidak bisa dipecah sebagian.
|
|
bool get isWeighted => weight != null && weight! > 0;
|
|
|
|
/// Singkatan satuan untuk ditampilkan; jatuh ke nama panjang bila kosong.
|
|
String get unitLabel =>
|
|
unitAbbreviation.isNotEmpty ? unitAbbreviation : unitName;
|
|
|
|
/// `4,2 ons` untuk baris berbobot, `2` untuk baris biasa.
|
|
///
|
|
/// Jangan menampilkan [quantity] mentah pada baris berbobot — nilainya selalu
|
|
/// 1 dan terbaca seperti "satu ons".
|
|
String get quantityLabel {
|
|
if (!isWeighted) return quantity.toString();
|
|
final value = weight!.weightFormat;
|
|
return unitLabel.isEmpty ? value : '$value $unitLabel';
|
|
}
|
|
|
|
/// `4,2 ons x Rp 4.500` atau `2 x Rp 25.000`.
|
|
String get quantityPriceLabel =>
|
|
'$quantityLabel x ${unitPrice.currencyFormatRpV2}';
|
|
}
|