Files
nearle_pos/lib/presentation/modules/screens/events_view.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

313 lines
11 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_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
/// End-of-day sync.
///
/// Shows what the terminal produced today and uploads every bill still at
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
/// and is retried on the next tap.
class EventsView extends ConsumerWidget {
const EventsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final report = ref.watch(todayReportProvider);
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
final syncState = ref.watch(orderSyncProvider);
final events = ref.watch(syncEventsProvider);
final r = report.value;
return ModulePage(
children: [
Wrap(
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
children: [
StatTile(
label: 'Bills Today',
value: '${r?.billCount ?? 0}',
icon: Icons.receipt_long_rounded,
caption: r?.firstBillAt == null
? 'no sales yet'
: '${Formatters.time(r!.firstBillAt!)} '
'${Formatters.time(r.lastBillAt!)}',
),
StatTile(
label: 'Items Sold',
value: (r?.itemCount ?? 0).toStringAsFixed(0),
icon: Icons.shopping_basket_rounded,
color: AppColors.info,
caption: 'units across all bills',
),
StatTile(
label: "Today's Sales",
value: Formatters.money(r?.grossSales ?? 0),
icon: Icons.payments_rounded,
color: AppColors.success,
caption: 'gross takings',
),
StatTile(
label: 'Awaiting Sync',
value: '$pending',
icon: Icons.cloud_off_rounded,
color: pending > 0 ? AppColors.warning : AppColors.success,
caption: pending > 0
? 'held on this terminal'
: 'everything uploaded',
),
],
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Upload bills to server',
subtitle: r == null
? 'Reading today\u2019s trading from SQLite\u2026'
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
'\u00b7 ${r.terminalId}',
action: TagChip(
pending > 0 ? '$pending pending' : 'All synced',
color: pending > 0 ? AppColors.warning : AppColors.success,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (r != null && !r.isEmpty) ...[
_row('Bills', '${r.billCount}'),
_row('Items sold', r.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(r.grossSales)),
_row('GST collected', Formatters.money(r.taxCollected)),
_row('Discount given', Formatters.money(r.discountGiven)),
_row('Average basket', Formatters.money(r.averageBasket)),
if (r.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
for (final e in r.paymentBreakdown.entries)
ProgressRow(
label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value),
fraction:
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
color: _methodColor(e.key),
),
],
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncRunning) ...[
Text(
syncState.stage,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.brPill,
child: LinearProgressIndicator(
value: syncState.progress,
minHeight: 8,
backgroundColor: AppColors.divider,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.primary),
),
),
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncFinished)
_outcomeBanner(syncState.outcome),
PrimaryButton(
label: pending > 0
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
: 'Nothing to sync',
icon: Icons.cloud_upload_rounded,
large: true,
busy: syncState is SyncRunning,
onPressed: pending == 0
? null
: () => ref.read(orderSyncProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.shield_outlined,
size: 15, color: AppColors.textTertiary,),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Bills are written to SQLite the moment a sale '
'completes. A failed upload changes nothing on disk — '
'every bill stays until the server confirms it.',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
height: 1.5,
),
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Orders',
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
child: ResponsiveTable(
columns: const [
TableCol('Invoice', flex: 3),
TableCol('Time', flex: 2, priority: 1),
TableCol('Total', flex: 2, numeric: true),
TableCol('Sync', flex: 2, numeric: true),
],
rows: rows
.map((o) => [
Cell(o.invoiceNumber, bold: true, mono: true),
Cell(Formatters.time(o.createdAt),
color: AppColors.textTertiary,),
Cell(Formatters.money(o.total), mono: true, bold: true),
TagChip(
o.isSynced ? 'Synced' : 'Pending',
color:
o.isSynced ? AppColors.success : AppColors.warning,
),
],)
.toList(),
),
),
if (events.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Sync history',
subtitle: 'This session',
child: ResponsiveTable(
columns: const [
TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true),
],
rows: events
.map((e) => [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
e.type.isInbound
? Icons.cloud_download_rounded
: Icons.cloud_upload_rounded,
size: 15,
color: AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(child: Cell(e.type.label, bold: true)),
],
),
Cell(
e.error ?? e.summary,
color: e.error != null
? AppColors.danger
: AppColors.textSecondary,
),
Cell(Formatters.time(e.createdAt),
color: AppColors.textTertiary,),
],)
.toList(),
),
),
],
],
);
}
Widget _outcomeBanner(SyncOutcome outcome) {
final ok = outcome.isSuccess;
final uploaded = outcome.uploaded;
final attempted = outcome.attempted;
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
size: 18,
color: ok ? AppColors.success : AppColors.danger,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
ok
? '$uploaded of $attempted bills uploaded and marked synced.'
: '${outcome.error}',
style: TextStyle(
fontSize: 13,
height: 1.45,
color: ok ? AppColors.success : AppColors.danger,
),
),
),
],
),
);
}
static Color _methodColor(PaymentMethod m) => switch (m) {
PaymentMethod.cash => AppColors.success,
PaymentMethod.card => AppColors.info,
PaymentMethod.upi => AppColors.primary,
PaymentMethod.wallet => AppColors.warning,
PaymentMethod.giftCard => AppColors.tierGold,
PaymentMethod.loyalty => AppColors.tierSilver,
};
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(
children: [
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
),
),
),
const SizedBox(width: AppSpacing.md),
Text(
value,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
);
}