third commit

This commit is contained in:
2026-07-31 17:06:52 +05:30
parent d9886880cc
commit 9891a69a5f
32 changed files with 2083 additions and 557 deletions

View File

@@ -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';
}

View File

@@ -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,
);
}

View File

@@ -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`.