third commit
This commit is contained in:
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user