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

@@ -36,15 +36,19 @@ class CatalogueSyncException implements Exception {
/// The real implementation would issue an HTTP request; the contract is the
/// same, so only this class changes.
class RemoteCatalogueSource {
RemoteCatalogueSource() {
RemoteCatalogueSource({required this.isOffline}) {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
}
/// Flipped from Settings to exercise the offline path.
bool simulateOffline = false;
/// Reads the Settings switch on every call.
///
/// Deliberately a callback rather than a stored bool: a copied flag can fall
/// out of step with the switch, which makes the terminal behave as offline
/// while showing that it is not.
final bool Function() isOffline;
/// Streams progress so the import screen can show a real bar rather than an
/// indeterminate spinner.
@@ -62,10 +66,10 @@ class RemoteCatalogueSource {
for (final (progress, stage) in stages) {
await Future<void>.delayed(const Duration(milliseconds: 320));
if (simulateOffline) {
if (isOffline()) {
throw const CatalogueSyncException(
'No connection to the catalogue server. '
'Check the network and try again.',
'Simulate offline is ON in Settings, so the catalogue pull was '
'failed on purpose. Turn it off to import.',
);
}
@@ -83,9 +87,9 @@ class RemoteCatalogueSource {
/// Stands in for the back-office order intake API.
class RemoteOrderSink {
RemoteOrderSink();
RemoteOrderSink({required this.isOffline});
bool simulateOffline = false;
final bool Function() isOffline;
/// Uploads a batch of orders and returns the ids the server accepted.
///
@@ -96,10 +100,10 @@ class RemoteOrderSink {
Duration(milliseconds: 400 + orders.length * 60),
);
if (simulateOffline) {
if (isOffline()) {
throw const CatalogueSyncException(
'Could not reach the order server. Every bill is still stored on '
'this terminal and will upload on the next attempt.',
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every bill is still stored on this terminal.',
);
}

View File

@@ -22,6 +22,7 @@ class SeedData {
emoji: '🥛',
unit: UnitOfMeasure.litre,
gstRate: 0.05,
hsnCode: '0401',
brand: 'Amul',
),
Product(
@@ -35,6 +36,7 @@ class SeedData {
stock: 30,
emoji: '🧈',
gstRate: 0.12,
hsnCode: '0405',
brand: 'Amul',
),
Product(
@@ -48,6 +50,7 @@ class SeedData {
stock: 40,
emoji: '🥣',
gstRate: 0.05,
hsnCode: '0403',
brand: 'Nandini',
),
Product(
@@ -60,6 +63,7 @@ class SeedData {
stock: 20,
emoji: '🧀',
gstRate: 0.05,
hsnCode: '0406',
brand: 'Milky Mist',
),
Product(
@@ -73,6 +77,7 @@ class SeedData {
stock: 25,
emoji: '🧀',
gstRate: 0.12,
hsnCode: '0406',
brand: 'Britannia',
),
@@ -89,6 +94,7 @@ class SeedData {
emoji: '🍚',
unit: UnitOfMeasure.kilogram,
gstRate: 0.05,
hsnCode: '1006',
brand: 'India Gate',
),
Product(
@@ -103,6 +109,7 @@ class SeedData {
emoji: '🛢️',
unit: UnitOfMeasure.litre,
gstRate: 0.05,
hsnCode: '1507',
brand: 'Fortune',
),
Product(
@@ -115,6 +122,7 @@ class SeedData {
stock: 45,
emoji: '🫘',
gstRate: 0.05,
hsnCode: '0713',
brand: 'Tata Sampann',
),
Product(
@@ -129,6 +137,7 @@ class SeedData {
emoji: '🌾',
unit: UnitOfMeasure.kilogram,
gstRate: 0.05,
hsnCode: '1101',
brand: 'Aashirvaad',
),
Product(
@@ -142,6 +151,7 @@ class SeedData {
emoji: '🍬',
unit: UnitOfMeasure.kilogram,
gstRate: 0.05,
hsnCode: '1701',
),
Product(
id: 'p015',
@@ -153,6 +163,7 @@ class SeedData {
stock: 120,
emoji: '🍜',
gstRate: 0.12,
hsnCode: '1902',
brand: 'Nestlé',
),
Product(
@@ -166,6 +177,7 @@ class SeedData {
emoji: '🧂',
unit: UnitOfMeasure.kilogram,
gstRate: 0.05,
hsnCode: '2501',
brand: 'Tata',
),
@@ -181,6 +193,7 @@ class SeedData {
emoji: '🥤',
unit: UnitOfMeasure.millilitre,
gstRate: 0.28,
hsnCode: '2202',
brand: 'Coca-Cola',
),
Product(
@@ -193,6 +206,7 @@ class SeedData {
stock: 100,
emoji: '🥭',
gstRate: 0.12,
hsnCode: '2202',
brand: 'Parle Agro',
),
Product(
@@ -206,6 +220,7 @@ class SeedData {
emoji: '💧',
unit: UnitOfMeasure.litre,
gstRate: 0.18,
hsnCode: '2201',
brand: 'Bisleri',
),
Product(
@@ -218,6 +233,7 @@ class SeedData {
stock: 40,
emoji: '🔋',
gstRate: 0.28,
hsnCode: '2202',
brand: 'Red Bull',
),
Product(
@@ -231,6 +247,7 @@ class SeedData {
stock: 30,
emoji: '',
gstRate: 0.18,
hsnCode: '2101',
brand: 'Bru',
),
@@ -245,6 +262,7 @@ class SeedData {
stock: 90,
emoji: '🍪',
gstRate: 0.18,
hsnCode: '1905',
brand: 'Parle',
),
Product(
@@ -257,6 +275,7 @@ class SeedData {
stock: 110,
emoji: '🥔',
gstRate: 0.18,
hsnCode: '2005',
brand: "Lay's",
),
Product(
@@ -269,6 +288,7 @@ class SeedData {
stock: 75,
emoji: '🍫',
gstRate: 0.18,
hsnCode: '1806',
brand: 'Cadbury',
),
Product(
@@ -281,6 +301,7 @@ class SeedData {
stock: 85,
emoji: '🍪',
gstRate: 0.18,
hsnCode: '1905',
brand: 'Britannia',
),
Product(
@@ -293,6 +314,7 @@ class SeedData {
stock: 60,
emoji: '🥜',
gstRate: 0.12,
hsnCode: '2106',
brand: 'Haldiram',
),
@@ -308,6 +330,7 @@ class SeedData {
stock: 55,
emoji: '🪥',
gstRate: 0.18,
hsnCode: '3306',
brand: 'Colgate',
),
Product(
@@ -320,6 +343,7 @@ class SeedData {
stock: 70,
emoji: '🧼',
gstRate: 0.18,
hsnCode: '3401',
brand: 'Dove',
),
Product(
@@ -333,6 +357,7 @@ class SeedData {
stock: 25,
emoji: '🧴',
gstRate: 0.18,
hsnCode: '3305',
brand: 'P&G',
),
Product(
@@ -345,6 +370,7 @@ class SeedData {
stock: 30,
emoji: '🧴',
gstRate: 0.18,
hsnCode: '3304',
brand: 'Nivea',
),
@@ -361,6 +387,7 @@ class SeedData {
emoji: '🧺',
unit: UnitOfMeasure.kilogram,
gstRate: 0.18,
hsnCode: '3402',
brand: 'Surf Excel',
),
Product(
@@ -373,6 +400,7 @@ class SeedData {
stock: 90,
emoji: '🧽',
gstRate: 0.18,
hsnCode: '3401',
brand: 'Vim',
),
Product(
@@ -385,6 +413,7 @@ class SeedData {
stock: 40,
emoji: '🧴',
gstRate: 0.18,
hsnCode: '3402',
brand: 'Harpic',
),
Product(
@@ -397,6 +426,7 @@ class SeedData {
stock: 35,
emoji: '🗑️',
gstRate: 0.18,
hsnCode: '3923',
),
// ----------------------------------------------------------- Fruits
@@ -411,6 +441,7 @@ class SeedData {
emoji: '🍌',
unit: UnitOfMeasure.kilogram,
gstRate: 0,
hsnCode: '0803',
),
Product(
id: 'p061',
@@ -423,6 +454,7 @@ class SeedData {
emoji: '🍎',
unit: UnitOfMeasure.kilogram,
gstRate: 0,
hsnCode: '0808',
),
Product(
id: 'p062',
@@ -435,6 +467,7 @@ class SeedData {
emoji: '🥭',
unit: UnitOfMeasure.kilogram,
gstRate: 0,
hsnCode: '0804',
),
// ------------------------------------------------------- Vegetables
@@ -449,6 +482,7 @@ class SeedData {
emoji: '🍅',
unit: UnitOfMeasure.kilogram,
gstRate: 0,
hsnCode: '0702',
),
Product(
id: 'p071',
@@ -461,6 +495,7 @@ class SeedData {
emoji: '🧅',
unit: UnitOfMeasure.kilogram,
gstRate: 0,
hsnCode: '0703',
),
Product(
id: 'p072',
@@ -473,6 +508,7 @@ class SeedData {
emoji: '🥔',
unit: UnitOfMeasure.kilogram,
gstRate: 0,
hsnCode: '0701',
),
Product(
id: 'p073',
@@ -484,6 +520,7 @@ class SeedData {
stock: 8,
emoji: '🥕',
gstRate: 0,
hsnCode: '0706',
),
];

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

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:uuid/uuid.dart';
import '../../core/utils/formatters.dart';
@@ -99,14 +101,37 @@ class SyncRepositoryImpl implements SyncRepository {
required String cashierName,
}) async {
final today = DateTime.now();
final orders = await _store.orders.forBusinessDate(today);
return ShiftReport.fromTransactions(
transactions: orders,
// Bills still held locally.
final live = ShiftReport.fromTransactions(
transactions: await _store.orders.forBusinessDate(today),
businessDate: today,
terminalId: terminalId,
cashierName: cashierName,
);
// Bills already uploaded and deleted survive only as archived totals.
final row = await _store.orders.dayArchive(today);
if (row == null) return live;
final payments = (jsonDecode(row['payments_json']! as String)
as Map<String, Object?>)
.map(
(k, v) => MapEntry(
PaymentMethod.values.byName(k),
(v! as num).toDouble(),
),
);
final archived = ShiftReport.fromArchive(
row: row,
payments: payments,
businessDate: today,
terminalId: terminalId,
cashierName: cashierName,
);
return archived + live;
}
@override
@@ -152,10 +177,13 @@ class SyncRepositoryImpl implements SyncRepository {
pending.map(_orderToPayload).toList(),
);
onProgress?.call(0.85, 'Marking bills as synced');
onProgress?.call(0.85, 'Clearing uploaded bills from this terminal');
// Only what the server confirmed is flipped to 1.
await _store.orders.markSynced(accepted);
// Only what the server confirmed is archived and removed. Anything it
// did not acknowledge stays on disk.
final acceptedOrders =
pending.where((o) => accepted.contains(o.id)).toList();
await _store.orders.archiveAndDelete(acceptedOrders);
await _store.refreshUnsyncedCount();
final rejected = ids.where((id) => !accepted.contains(id)).toList();