183 lines
5.9 KiB
Dart
183 lines
5.9 KiB
Dart
import 'package:uuid/uuid.dart';
|
|
|
|
import '../models/order_models.dart';
|
|
import 'company_profile_service.dart';
|
|
import 'sales_order_repository.dart';
|
|
|
|
class SalesOrderLineInput {
|
|
const SalesOrderLineInput({
|
|
required this.description,
|
|
required this.quantity,
|
|
required this.unitPrice,
|
|
this.productId,
|
|
this.taxRate,
|
|
});
|
|
|
|
final String description;
|
|
final int quantity;
|
|
final int unitPrice;
|
|
final String? productId;
|
|
final double? taxRate;
|
|
}
|
|
|
|
class SalesOrderService {
|
|
SalesOrderService({
|
|
SalesOrderRepository? repository,
|
|
CompanyProfileService? companyProfileService,
|
|
}) : _repository = repository ?? SalesOrderRepository(),
|
|
_companyProfileService = companyProfileService ?? CompanyProfileService();
|
|
|
|
final SalesOrderRepository _repository;
|
|
final CompanyProfileService _companyProfileService;
|
|
final Uuid _uuid = const Uuid();
|
|
|
|
static const Map<SalesOrderStatus, List<SalesOrderStatus>> _transitions = {
|
|
SalesOrderStatus.draft: [SalesOrderStatus.confirmed, SalesOrderStatus.cancelled],
|
|
SalesOrderStatus.confirmed: [SalesOrderStatus.picking, SalesOrderStatus.cancelled],
|
|
SalesOrderStatus.picking: [SalesOrderStatus.shipped, SalesOrderStatus.cancelled],
|
|
SalesOrderStatus.shipped: [SalesOrderStatus.closed],
|
|
SalesOrderStatus.closed: [],
|
|
SalesOrderStatus.cancelled: [],
|
|
};
|
|
|
|
Future<List<SalesOrder>> fetchOrders({SalesOrderStatus? status}) {
|
|
return _repository.fetchOrders(status: status);
|
|
}
|
|
|
|
Future<SalesOrder> createOrder({
|
|
required String customerId,
|
|
required String customerName,
|
|
List<SalesOrderLineInput> lines = const [],
|
|
DateTime? requestedShipDate,
|
|
String? notes,
|
|
String? assignedTo,
|
|
}) async {
|
|
final profile = await _companyProfileService.loadProfile();
|
|
final now = DateTime.now();
|
|
final orderId = _uuid.v4();
|
|
final lineItems = _buildItems(orderId, lines);
|
|
final order = SalesOrder(
|
|
id: orderId,
|
|
orderNumber: _generateOrderNumber(now),
|
|
customerId: customerId,
|
|
customerNameSnapshot: customerName,
|
|
orderDate: now,
|
|
requestedShipDate: requestedShipDate,
|
|
status: SalesOrderStatus.draft,
|
|
subtotal: 0,
|
|
taxAmount: 0,
|
|
totalAmount: 0,
|
|
notes: notes,
|
|
assignedTo: assignedTo,
|
|
workflowStage: _workflowStage(SalesOrderStatus.draft),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
items: lineItems,
|
|
).recalculateTotals(defaultTaxRate: profile.taxRate);
|
|
|
|
await _repository.upsertOrder(order);
|
|
return order;
|
|
}
|
|
|
|
Future<SalesOrder> updateOrder(
|
|
SalesOrder order, {
|
|
List<SalesOrderLineInput>? replacedLines,
|
|
DateTime? requestedShipDate,
|
|
String? notes,
|
|
String? assignedTo,
|
|
}) async {
|
|
final profile = await _companyProfileService.loadProfile();
|
|
final now = DateTime.now();
|
|
final nextItems = replacedLines != null ? _buildItems(order.id, replacedLines) : order.items;
|
|
final updated = order
|
|
.copyWith(
|
|
requestedShipDate: requestedShipDate ?? order.requestedShipDate,
|
|
notes: notes ?? order.notes,
|
|
assignedTo: assignedTo ?? order.assignedTo,
|
|
updatedAt: now,
|
|
items: nextItems,
|
|
)
|
|
.recalculateTotals(defaultTaxRate: profile.taxRate);
|
|
await _repository.upsertOrder(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<SalesOrder> transitionStatus(String orderId, SalesOrderStatus nextStatus, {bool force = false}) async {
|
|
final order = await _repository.findById(orderId);
|
|
if (order == null) {
|
|
throw StateError('order not found: $orderId');
|
|
}
|
|
if (!force && !_canTransition(order.status, nextStatus)) {
|
|
throw StateError('invalid transition ${order.status.name} -> ${nextStatus.name}');
|
|
}
|
|
final now = DateTime.now();
|
|
final updated = order.copyWith(
|
|
status: nextStatus,
|
|
workflowStage: _workflowStage(nextStatus),
|
|
updatedAt: now,
|
|
);
|
|
await _repository.upsertOrder(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<SalesOrder> advanceStatus(String orderId) async {
|
|
final order = await _repository.findById(orderId);
|
|
if (order == null) {
|
|
throw StateError('order not found: $orderId');
|
|
}
|
|
final candidates = _transitions[order.status];
|
|
if (candidates == null || candidates.isEmpty) {
|
|
return order;
|
|
}
|
|
return transitionStatus(orderId, candidates.first);
|
|
}
|
|
|
|
bool _canTransition(SalesOrderStatus current, SalesOrderStatus next) {
|
|
final allowed = _transitions[current];
|
|
return allowed?.contains(next) ?? false;
|
|
}
|
|
|
|
List<SalesOrderStatus> nextStatuses(SalesOrderStatus current) {
|
|
return List.unmodifiable(_transitions[current] ?? const []);
|
|
}
|
|
|
|
List<SalesOrderItem> _buildItems(String orderId, List<SalesOrderLineInput> lines) {
|
|
return lines.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final line = entry.value;
|
|
return SalesOrderItem(
|
|
id: _uuid.v4(),
|
|
orderId: orderId,
|
|
productId: line.productId,
|
|
description: line.description,
|
|
quantity: line.quantity,
|
|
unitPrice: line.unitPrice,
|
|
taxRate: line.taxRate ?? 0,
|
|
sortIndex: index,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
String _generateOrderNumber(DateTime timestamp) {
|
|
final datePart = '${timestamp.year}${timestamp.month.toString().padLeft(2, '0')}${timestamp.day.toString().padLeft(2, '0')}';
|
|
final timePart = '${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}';
|
|
return 'SO$datePart-$timePart-${timestamp.millisecondsSinceEpoch % 1000}'.toUpperCase();
|
|
}
|
|
|
|
String _workflowStage(SalesOrderStatus status) {
|
|
switch (status) {
|
|
case SalesOrderStatus.draft:
|
|
return 'order';
|
|
case SalesOrderStatus.confirmed:
|
|
return 'ready';
|
|
case SalesOrderStatus.picking:
|
|
return 'picking';
|
|
case SalesOrderStatus.shipped:
|
|
return 'shipping';
|
|
case SalesOrderStatus.closed:
|
|
return 'closed';
|
|
case SalesOrderStatus.cancelled:
|
|
return 'cancelled';
|
|
}
|
|
}
|
|
}
|