Files
nearle_pos/lib/presentation/pos/screens/pos_dashboard_screen.dart
Suriya af3933092f Fix billing data integrity, sale atomicity and stock safety
Bills were persisted correctly but read back wrong. The read path rebuilt a
cart from its lines alone, dropping bill-level discounts and loyalty, so every
figure derived from a stored bill was overstated: the upload payload, the day
archive and the shift report. A discounted 529 bill read back as 620.

Money and data integrity
- order_dao: restore bill_discount and points_redeemed when rebuilding a cart;
  keep the reconstruction tier-less so the membership discount is not applied
  twice. Trust the recorded total and points via SaleTransaction.storedTotal.
- checkout_sale + order_dao.commitSale: write the bill, its stock movement and
  the loyalty update in one transaction. Previously a failure part-way through
  left a persisted bill the cashier believed had failed, inviting a duplicate.
- checkout_sale: re-check every line against live stock. A parked bill resumed
  after its stock was sold passed validation and oversold.
- catalogue_dao: allocate the invoice sequence in one transaction; the previous
  read-modify-write could hand two sales the same number and fail UNIQUE.
- local_store: replay unsynced sales after a catalogue import, so a mid-shift
  re-import cannot restore stock that has already been sold.
- payment_controller: stamp the signed-in operator on the bill instead of the
  hardcoded seed session, and pass the terminal id through.
- cart: reconcile per-slab GST against the bill total so the parts sum to the
  whole on a tax invoice.

Sync and reporting
- sync_repository: drain unsynced bills in a loop rather than silently capping
  at one page; stop on rejection so rejected rows cannot loop forever.
- sync_log_dao (new): persist the sync history to the sync_log table, which the
  schema already defined but nothing used. It was in memory, so the only record
  that bills had been uploaded died at restart.
- Scope shift reports by cashier. day_archive is re-keyed to
  (business_date, cashier_name) so a till stays settleable after its bills are
  uploaded and deleted. Schema v4 with a migration that carries v3 rows across.

Input and UI
- barcode_service: consume machine-paced keystrokes so a scan cannot also land
  in the focused field, and raise the bar to 60ms/char while a text field has
  focus so typing a mobile number is not read as a scan. Clock and focus check
  injected so the behaviour is testable.
- primary_button: make the label flexible; label plus trailing total overflowed
  the Charge button by up to 131px.
- app_router: redirect instead of null-casting when the receipt route is
  entered without its transaction.
- customer_repository: reduce the search query to digits so a punctuated mobile
  number matches.

Cleanup
- Remove TransactionRepository.save, CustomerRepository.recordSale and
  OrderDao.insertOrder, all superseded by commitSale.
- dart fix across the tree; 251 analyzer issues down to 3 info-level.

Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke
the existing suite (containsAll type argument, reset() needing a catalogue) and
deleted the leftover template test. Added coverage for the order round trip,
the day archive after a real sync, stock safety, checkout atomicity, the v3->v4
migration, scanner-versus-human input, and an app-level smoke test that renders
every module.

Note: bills already uploaded with a discount went up overstated. This stops it
happening again but does not correct historical server data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:34:10 +05:30

176 lines
6.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/barcode_service.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../modules/screens/customers_view.dart';
import '../../modules/screens/events_view.dart';
import '../../modules/screens/product_import_view.dart';
import '../../modules/screens/promos_view.dart';
import '../../modules/screens/settings_view.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/cart_controller.dart';
import '../providers/catalog_providers.dart';
import '../providers/navigation_provider.dart';
import '../widgets/app_sidebar.dart';
import '../widgets/billing_panel.dart';
import '../widgets/cart_fab.dart';
import '../widgets/page_header.dart';
import 'pos_view.dart';
/// Application shell.
///
/// Owns the sidebar, page header and the body for whichever module is active.
/// Keeping one shell means navigation never rebuilds the chrome, and the
/// scanner stays live across modules.
///
/// Layout collapses in a fixed order as width shrinks:
///
/// * `>= 1300` sidebar with labels, docked bill
/// * `11201300` sidebar as an icon rail, docked bill
/// * `9201120` icon rail, bill becomes a bottom sheet
/// * `< 920` sidebar goes off-canvas behind a menu button
class PosDashboardScreen extends ConsumerStatefulWidget {
const PosDashboardScreen({super.key});
@override
ConsumerState<PosDashboardScreen> createState() => _PosDashboardScreenState();
}
class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final FocusNode _searchFocus = FocusNode();
late final BarcodeService _barcode;
@override
void initState() {
super.initState();
// The scanner behaves like a keyboard, so listen globally rather than
// depending on any one field holding focus. A scan from another module
// jumps back to billing, which is what a cashier expects.
_barcode = BarcodeService(
onScan: (code) {
// Without an imported catalogue there is nothing to resolve against.
if (!ref.read(catalogueReadyProvider)) return;
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
// Drop any character the burst leaked into the search box before the
// scan was recognised, so the grid is not left filtered to nothing.
ref.read(searchQueryProvider.notifier).state = '';
ref.read(cartControllerProvider.notifier).scanBarcode(code);
},
)..attach();
}
@override
void dispose() {
_barcode.dispose();
_searchFocus.dispose();
super.dispose();
}
void _openBillingSheet() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const FractionallySizedBox(
heightFactor: 0.92,
child: ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
child: BillingPanel(inSheet: true),
),
),
);
}
Widget _body(PosModule module, PosLayout layout) => switch (module) {
PosModule.pos => PosView(layout: layout, searchFocus: _searchFocus),
PosModule.customers => const CustomersView(),
PosModule.productImport => const ProductImportView(),
PosModule.promos => const PromosView(),
PosModule.events => const EventsView(),
PosModule.settings => const SettingsView(),
};
@override
Widget build(BuildContext context) {
final layout = PosLayout.of(context);
final module = ref.watch(activeModuleProvider);
final ready = ref.watch(catalogueReadyProvider);
final isPos = module == PosModule.pos && ready;
// Only the terminal itself needs the bill docked beside it.
final showDockedBill = isPos && !layout.billingIsSheet;
final showCartFab = isPos && layout.billingIsSheet;
return Scaffold(
key: _scaffoldKey,
backgroundColor: AppColors.background,
drawer: layout.sidebarIsDrawer
? Drawer(
width: PosLayout.expandedWidth,
backgroundColor: AppColors.surface,
child: AppSidebar(
mode: SidebarMode.expanded,
onDestinationTap: () => Navigator.of(context).maybePop(),
),
)
: null,
floatingActionButton:
showCartFab ? CartFab(onTap: _openBillingSheet) : null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
body: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.f2):
_searchFocus.requestFocus,
const SingleActivator(LogicalKeyboardKey.f8): () =>
ref.read(cartControllerProvider.notifier).undo(),
const SingleActivator(LogicalKeyboardKey.escape):
_searchFocus.unfocus,
},
child: Focus(
autofocus: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
Expanded(
child: Column(
children: [
PageHeader(
layout: layout,
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
),
Expanded(
child: AnimatedSwitcher(
duration: AppMotion.fast,
child: KeyedSubtree(
key: ValueKey(module),
child: _body(module, layout),
),
),
),
],
),
),
if (showDockedBill) ...[
const VerticalDivider(width: 1),
SizedBox(
width: layout.billingWidth,
child: const BillingPanel(),
),
],
],
),
),
),
);
}
}