Files
nearle_pos/lib/presentation/pos/widgets/discount_sheet.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

214 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.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/cart.dart';
import '../providers/cart_controller.dart';
Future<void> showLineDiscountSheet(
BuildContext context,
WidgetRef ref,
CartLine line,
) {
return _show(
context: context,
title: line.product.name,
subtitle: 'Line value ${Formatters.money(line.grossAmount)}',
current: line.discount,
onApply: (d) => ref
.read(cartControllerProvider.notifier)
.applyLineDiscount(line.product.id, d),
);
}
Future<void> showBillDiscountSheet(BuildContext context, WidgetRef ref) {
final cart = ref.read(cartControllerProvider);
return _show(
context: context,
title: 'Bill discount',
subtitle: 'Subtotal ${Formatters.money(cart.subtotal)}',
current: cart.billDiscount,
onApply: (d) =>
ref.read(cartControllerProvider.notifier).applyBillDiscount(d),
);
}
Future<void> _show({
required BuildContext context,
required String title,
required String subtitle,
required Discount current,
required ValueChanged<Discount> onApply,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _DiscountSheet(
title: title,
subtitle: subtitle,
current: current,
onApply: onApply,
),
);
}
class _DiscountSheet extends StatefulWidget {
const _DiscountSheet({
required this.title,
required this.subtitle,
required this.current,
required this.onApply,
});
final String title;
final String subtitle;
final Discount current;
final ValueChanged<Discount> onApply;
@override
State<_DiscountSheet> createState() => _DiscountSheetState();
}
class _DiscountSheetState extends State<_DiscountSheet> {
late DiscountType _type =
widget.current.type == DiscountType.none
? DiscountType.percentage
: widget.current.type;
late final TextEditingController _value = TextEditingController(
text: widget.current.isActive
? widget.current.value.toStringAsFixed(0)
: '',
);
@override
void dispose() {
_value.dispose();
super.dispose();
}
void _apply() {
final v = double.tryParse(_value.text.trim()) ?? 0;
widget.onApply(
v <= 0 ? Discount.none : Discount(type: _type, value: v),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xxl),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 40,
height: 4,
decoration: const BoxDecoration(
color: AppColors.border,
borderRadius: AppRadius.brPill,
),
),
const SizedBox(height: AppSpacing.xl),
Text(widget.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),),
const SizedBox(height: 2),
Text(widget.subtitle,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),),
const SizedBox(height: AppSpacing.xxl),
SegmentedButton<DiscountType>(
segments: const [
ButtonSegment(
value: DiscountType.percentage,
label: Text('Percent'),
icon: Icon(Icons.percent_rounded, size: 17),
),
ButtonSegment(
value: DiscountType.flat,
label: Text('Flat'),
icon: Icon(Icons.currency_rupee_rounded, size: 17),
),
],
selected: {_type},
onSelectionChanged: (s) => setState(() => _type = s.first),
),
const SizedBox(height: AppSpacing.xl),
TextField(
controller: _value,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '0',
prefixText: _type == DiscountType.flat ? '' : null,
suffixText: _type == DiscountType.percentage ? '%' : null,
),
onSubmitted: (_) => _apply(),
),
const SizedBox(height: AppSpacing.lg),
Wrap(
spacing: AppSpacing.sm,
children: (_type == DiscountType.percentage
? const [5, 10, 15, 20, 25]
: const [10, 20, 50, 100, 200])
.map((v) => ActionChip(
label: Text(_type == DiscountType.percentage
? '$v%'
: '$v',),
onPressed: () =>
setState(() => _value.text = v.toString()),
),)
.toList(),
),
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: PrimaryButton(
label: 'Remove',
tone: ButtonTone.neutral,
onPressed: () {
widget.onApply(Discount.none);
Navigator.of(context).pop();
},
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
flex: 2,
child: PrimaryButton(
label: 'Apply discount',
icon: Icons.check_rounded,
onPressed: _apply,
),
),
],),
],),
),
);
}
}