third commit
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
@@ -16,7 +14,7 @@ class AppDatabase {
|
||||
static final AppDatabase instance = AppDatabase._();
|
||||
|
||||
static const String _fileName = 'nearle_pos.db';
|
||||
static const int _version = 1;
|
||||
static const int _version = 3;
|
||||
|
||||
Database? _db;
|
||||
|
||||
@@ -37,7 +35,21 @@ class AppDatabase {
|
||||
Future<void> open({String? overridePath}) async {
|
||||
if (_db != null) return;
|
||||
|
||||
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||
if (kIsWeb) {
|
||||
// sqflite has no web implementation. Fail loudly here rather than
|
||||
// letting databaseFactory throw something unreadable further down.
|
||||
throw UnsupportedError(
|
||||
'Nearle POS stores bills in SQLite, which has no web implementation. '
|
||||
'Run the app on macOS, Windows, Linux or an Android tablet.',
|
||||
);
|
||||
}
|
||||
|
||||
const desktop = {
|
||||
TargetPlatform.windows,
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.macOS,
|
||||
};
|
||||
if (desktop.contains(defaultTargetPlatform)) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
@@ -52,7 +64,12 @@ class AppDatabase {
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, version) async => _createSchema(db),
|
||||
onUpgrade: (db, from, to) async {
|
||||
// Single version so far; migrations land here as the schema grows.
|
||||
if (from < 2) await db.execute(_createDayArchive);
|
||||
if (from < 3) {
|
||||
await db.execute(
|
||||
'ALTER TABLE ${Tables.products} ADD COLUMN hsn_code TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -84,6 +101,7 @@ class AppDatabase {
|
||||
for (final t in const [
|
||||
Tables.orderItems,
|
||||
Tables.orders,
|
||||
Tables.dayArchive,
|
||||
Tables.products,
|
||||
Tables.customers,
|
||||
Tables.parkedBills,
|
||||
@@ -111,6 +129,7 @@ class AppDatabase {
|
||||
image_url TEXT,
|
||||
unit TEXT NOT NULL DEFAULT 'piece',
|
||||
gst_rate REAL NOT NULL DEFAULT 0.18,
|
||||
hsn_code TEXT,
|
||||
brand TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -232,6 +251,9 @@ class AppDatabase {
|
||||
)
|
||||
''');
|
||||
|
||||
// ------------------------------------------------------------- archive
|
||||
await db.execute(_createDayArchive);
|
||||
|
||||
// ----------------------------------------------------------------- meta
|
||||
await db.execute('''
|
||||
CREATE TABLE ${Tables.meta} (
|
||||
@@ -242,9 +264,34 @@ class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Running totals per business day.
|
||||
///
|
||||
/// Synced orders are deleted from the terminal, so their figures are folded in
|
||||
/// here first — otherwise "Bills Today" would collapse to zero the moment a
|
||||
/// mid-shift sync ran.
|
||||
const String _createDayArchive = '''
|
||||
CREATE TABLE day_archive (
|
||||
business_date TEXT PRIMARY KEY,
|
||||
bill_count INTEGER NOT NULL DEFAULT 0,
|
||||
item_count REAL NOT NULL DEFAULT 0,
|
||||
gross_sales REAL NOT NULL DEFAULT 0,
|
||||
tax_collected REAL NOT NULL DEFAULT 0,
|
||||
discount_given REAL NOT NULL DEFAULT 0,
|
||||
round_off REAL NOT NULL DEFAULT 0,
|
||||
points_issued INTEGER NOT NULL DEFAULT 0,
|
||||
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
||||
payments_json TEXT NOT NULL DEFAULT '{}',
|
||||
first_bill_at INTEGER,
|
||||
last_bill_at INTEGER,
|
||||
synced_bills INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''';
|
||||
|
||||
class Tables {
|
||||
const Tables._();
|
||||
|
||||
static const String dayArchive = 'day_archive';
|
||||
|
||||
static const String products = 'products';
|
||||
static const String customers = 'customers';
|
||||
static const String orders = 'orders';
|
||||
@@ -260,4 +307,11 @@ class MetaKeys {
|
||||
static const String lastImportAt = 'last_import_at';
|
||||
static const String catalogueRevision = 'catalogue_revision';
|
||||
static const String invoiceSequence = 'invoice_sequence';
|
||||
|
||||
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
||||
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
||||
static const String printerUrl = 'printer_url';
|
||||
static const String printerName = 'printer_name';
|
||||
static const String autoPrint = 'auto_print';
|
||||
static const String openDrawer = 'open_cash_drawer';
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class CatalogueDao {
|
||||
'image_url': p.imageUrl,
|
||||
'unit': p.unit.name,
|
||||
'gst_rate': p.gstRate,
|
||||
'hsn_code': p.hsnCode,
|
||||
'brand': p.brand,
|
||||
'is_active': p.isActive ? 1 : 0,
|
||||
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||
@@ -42,6 +43,7 @@ class CatalogueDao {
|
||||
imageUrl: r['image_url'] as String?,
|
||||
unit: UnitOfMeasure.values.byName((r['unit'] as String?) ?? 'piece'),
|
||||
gstRate: (r['gst_rate']! as num).toDouble(),
|
||||
hsnCode: r['hsn_code'] as String?,
|
||||
brand: r['brand'] as String?,
|
||||
isActive: (r['is_active']! as int) == 1,
|
||||
);
|
||||
@@ -125,27 +127,34 @@ class CatalogueDao {
|
||||
|
||||
/// Writes the pulled catalogue in one transaction.
|
||||
///
|
||||
/// Stock already decremented by local sales is preserved for products that
|
||||
/// survive the import, so re-importing mid-shift cannot resurrect sold units.
|
||||
/// The server's stock figure wins outright. Local sales have already been
|
||||
/// uploaded and deleted by the time a re-import happens, so the server count
|
||||
/// is the corrected one — keeping the local number would double-count the
|
||||
/// units that were sold.
|
||||
Future<void> replaceCatalogue({
|
||||
required List<Product> products,
|
||||
required List<Customer> customers,
|
||||
}) async {
|
||||
await _db.transaction((txn) async {
|
||||
final existing = await txn.query(
|
||||
Tables.products,
|
||||
columns: ['id', 'stock'],
|
||||
);
|
||||
final heldStock = {
|
||||
for (final r in existing) r['id']! as String: (r['stock']! as num).toDouble(),
|
||||
};
|
||||
final incoming = products.map((p) => p.id).toSet();
|
||||
|
||||
// Products the server no longer lists are withdrawn from sale.
|
||||
final existing = await txn.query(Tables.products, columns: ['id']);
|
||||
final stale = existing
|
||||
.map((r) => r['id']! as String)
|
||||
.where((id) => !incoming.contains(id))
|
||||
.toList();
|
||||
|
||||
final batch = txn.batch();
|
||||
|
||||
for (final id in stale) {
|
||||
batch.delete(Tables.products, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
for (final p in products) {
|
||||
final held = heldStock[p.id];
|
||||
batch.insert(
|
||||
Tables.products,
|
||||
productToRow(held == null ? p : p.copyWith(stock: held)),
|
||||
productToRow(p),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,17 +171,111 @@ class OrderDao {
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- Sync
|
||||
/// Flips accepted orders to `sync_status = 1`.
|
||||
Future<void> markSynced(List<String> orderIds) async {
|
||||
if (orderIds.isEmpty) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final placeholders = List.filled(orderIds.length, '?').join(',');
|
||||
/// Folds accepted orders into the day archive, then deletes them.
|
||||
///
|
||||
/// Once the server holds a bill the terminal has no reason to keep it, so
|
||||
/// the rows go. Their figures are added to [Tables.dayArchive] first, so the
|
||||
/// shift totals a cashier sees do not collapse after a mid-shift sync.
|
||||
/// Both steps run in one transaction: if the delete fails the archive is
|
||||
/// rolled back with it, and nothing is counted twice.
|
||||
Future<void> archiveAndDelete(List<SaleTransaction> orders) async {
|
||||
if (orders.isEmpty) return;
|
||||
|
||||
await _db.rawUpdate(
|
||||
'UPDATE ${Tables.orders} SET sync_status = ?, synced_at = ?, '
|
||||
'sync_error = NULL WHERE id IN ($placeholders)',
|
||||
[synced, now, ...orderIds],
|
||||
await _db.transaction((txn) async {
|
||||
final byDate = <String, List<SaleTransaction>>{};
|
||||
for (final o in orders) {
|
||||
byDate.putIfAbsent(businessDateOf(o.createdAt), () => []).add(o);
|
||||
}
|
||||
|
||||
for (final entry in byDate.entries) {
|
||||
final date = entry.key;
|
||||
final batchOrders = entry.value;
|
||||
|
||||
final prior = await txn.query(
|
||||
Tables.dayArchive,
|
||||
where: 'business_date = ?',
|
||||
whereArgs: [date],
|
||||
limit: 1,
|
||||
);
|
||||
final existing = prior.isEmpty ? null : prior.first;
|
||||
|
||||
final priorPayments = existing == null
|
||||
? <String, double>{}
|
||||
: (jsonDecode(existing['payments_json']! as String)
|
||||
as Map<String, Object?>)
|
||||
.map((k, v) => MapEntry(k, (v! as num).toDouble()));
|
||||
|
||||
for (final o in batchOrders) {
|
||||
for (final p in o.payments) {
|
||||
priorPayments[p.method.name] =
|
||||
(priorPayments[p.method.name] ?? 0) + p.amount;
|
||||
}
|
||||
}
|
||||
|
||||
final firsts = batchOrders
|
||||
.map((o) => o.createdAt.millisecondsSinceEpoch)
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
await txn.insert(
|
||||
Tables.dayArchive,
|
||||
{
|
||||
'business_date': date,
|
||||
'bill_count':
|
||||
((existing?['bill_count'] as int?) ?? 0) + batchOrders.length,
|
||||
'item_count': ((existing?['item_count'] as num?)?.toDouble() ?? 0) +
|
||||
batchOrders.fold(0.0, (s, o) => s + o.cart.totalQuantity),
|
||||
'gross_sales':
|
||||
((existing?['gross_sales'] as num?)?.toDouble() ?? 0) +
|
||||
batchOrders.fold(0.0, (s, o) => s + o.total),
|
||||
'tax_collected':
|
||||
((existing?['tax_collected'] as num?)?.toDouble() ?? 0) +
|
||||
batchOrders.fold(0.0, (s, o) => s + o.cart.taxAmount),
|
||||
'discount_given':
|
||||
((existing?['discount_given'] as num?)?.toDouble() ?? 0) +
|
||||
batchOrders.fold(
|
||||
0.0,
|
||||
(s, o) =>
|
||||
s +
|
||||
o.cart.billDiscountTotal +
|
||||
o.cart.lineDiscountTotal,
|
||||
),
|
||||
'round_off': ((existing?['round_off'] as num?)?.toDouble() ?? 0) +
|
||||
batchOrders.fold(0.0, (s, o) => s + o.cart.roundOff),
|
||||
'points_issued': ((existing?['points_issued'] as int?) ?? 0) +
|
||||
batchOrders.fold(0, (s, o) => s + o.pointsEarned),
|
||||
'points_redeemed': ((existing?['points_redeemed'] as int?) ?? 0) +
|
||||
batchOrders.fold(0, (s, o) => s + o.pointsRedeemed),
|
||||
'payments_json': jsonEncode(priorPayments),
|
||||
'first_bill_at': existing?['first_bill_at'] ?? firsts.first,
|
||||
'last_bill_at': firsts.last,
|
||||
'synced_bills':
|
||||
((existing?['synced_bills'] as int?) ?? 0) + batchOrders.length,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
// order_items goes with it via ON DELETE CASCADE.
|
||||
final ids = orders.map((o) => o.id).toList();
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
await txn.delete(
|
||||
Tables.orders,
|
||||
where: 'id IN ($placeholders)',
|
||||
whereArgs: ids,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Archived figures for a business day, or null if nothing has synced yet.
|
||||
Future<Map<String, Object?>?> dayArchive(DateTime day) async {
|
||||
final rows = await _db.query(
|
||||
Tables.dayArchive,
|
||||
where: 'business_date = ?',
|
||||
whereArgs: [businessDateOf(day)],
|
||||
limit: 1,
|
||||
);
|
||||
return rows.isEmpty ? null : rows.first;
|
||||
}
|
||||
|
||||
/// Records a failed attempt. The rows stay at `sync_status = 0`.
|
||||
|
||||
Reference in New Issue
Block a user