Bug 40445: Implement optional two-phase cashup workflow
This patch implements an optional two-phase cashup workflow for point of sale operations, along with improved error handling and validation. Two-Phase Cashup Workflow: - Staff can initiate cashup (CASHUP_START) and complete later - Prevents new transactions on registers with cashups in progress - Tracks cashup sessions with start/end timestamps - Supports both quick cashup (immediate) and staged cashup workflows - Updates batch cashup workflow operations across multiple registers Error Handling & Validation: - Centralized exception handling for cashup operations - Clear validation messages for missing parameters - Prevents cashup when no cash transactions exist - Improved handling of zero/negative amounts - Informative error messages for user guidance Negative Amount Support: - Supports negative cashup amounts for cashup deficits - Automatic detection when actual cash is less than expected amount - Creates appropriate CASHUP_DEFICIT records - UI handles both positive and negative reconciliation amounts - Updated calculations and display logic throughout Modal Refactoring: - Eliminates code duplication between register.tt and registers.tt - Centralized cashup modal functionality in cashup_modals.js - Shared confirm_cashup.inc and trigger_cashup.inc templates - Consistent user experience across all cashup workflows - Improved maintainability and code organization Backend changes (Koha::Cash::Register): - start_cashup(): Creates CASHUP_START action to begin session - cashup_in_progress(): Detects active cashup sessions - add_cashup(): Enhanced with two-phase completion support - outstanding_accountlines(): Respects cashup session boundaries Frontend changes: - Interactive modals for starting and completing cashups - Real-time calculation displays - Support for reconciliation with actual amounts - Batch operations UI for multiple registers - Responsive error messaging and validation feedback Test plan: 1. Apply patches and restart services 2. Run prove t/db_dependent/Koha/Cash/Register.t 3. Run prove t/db_dependent/Koha/Cash/Register/Cashup.t 4. Test single register workflows: - Start cashup, add transactions, verify blocked - Complete cashup with reconciliation amounts - Test with positive, negative, and zero amounts 5. Test multi-register workflows: - Select multiple registers on registers page - Perform batch cashup operations - Verify each register processes correctly 6. Test error conditions: - Attempt cashup with no transactions - Attempt cashup with invalid amounts - Verify clear error messages displayed Sponsored-by: OpenFifth <https://openfifth.co.uk/> Signed-off-by: Jackie Usher <jackie.usher@westsussex.gov.uk> Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com> Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
parent
6d5c2f463f
commit
9db47a47c1
|
|
@ -16,6 +16,9 @@ package Koha::Cash::Register;
|
|||
# along with Koha; if not, see <https://www.gnu.org/licenses>.
|
||||
|
||||
use Modern::Perl;
|
||||
use DateTime;
|
||||
use Scalar::Util qw( looks_like_number );
|
||||
use Try::Tiny qw( catch try );
|
||||
|
||||
use Koha::Account;
|
||||
use Koha::Account::Line;
|
||||
|
|
@ -25,6 +28,7 @@ use Koha::Account::Offsets;
|
|||
use Koha::Cash::Register::Actions;
|
||||
use Koha::Cash::Register::Cashups;
|
||||
use Koha::Database;
|
||||
use Koha::DateUtils qw( dt_from_string );
|
||||
|
||||
use base qw(Koha::Object);
|
||||
|
||||
|
|
@ -115,35 +119,14 @@ Return a set of accountlines linked to this cash register since the last cashup
|
|||
sub outstanding_accountlines {
|
||||
my ( $self, $conditions, $attrs ) = @_;
|
||||
|
||||
my $since = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{ 'code' => 'CASHUP' },
|
||||
{
|
||||
order_by => { '-desc' => [ 'timestamp', 'id' ] },
|
||||
rows => 1
|
||||
}
|
||||
);
|
||||
# Find the start timestamp for the current "open" session
|
||||
my $start_timestamp = $self->_get_session_start_timestamp;
|
||||
|
||||
my $local_conditions =
|
||||
$since->count
|
||||
? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
|
||||
$start_timestamp
|
||||
? { 'date' => { '>' => $start_timestamp } }
|
||||
: {};
|
||||
|
||||
# Exclude reconciliation accountlines from outstanding accountlines
|
||||
$local_conditions->{'-and'} = [
|
||||
{
|
||||
'-or' => [
|
||||
{ 'credit_type_code' => { '!=' => 'CASHUP_SURPLUS' } },
|
||||
{ 'credit_type_code' => undef }
|
||||
]
|
||||
},
|
||||
{
|
||||
'-or' => [
|
||||
{ 'debit_type_code' => { '!=' => 'CASHUP_DEFICIT' } },
|
||||
{ 'debit_type_code' => undef }
|
||||
]
|
||||
}
|
||||
];
|
||||
my $merged_conditions =
|
||||
$conditions
|
||||
? { %{$conditions}, %{$local_conditions} }
|
||||
|
|
@ -157,6 +140,40 @@ sub outstanding_accountlines {
|
|||
return Koha::Account::Lines->_new_from_dbic($rs);
|
||||
}
|
||||
|
||||
=head3 cashup_in_progress
|
||||
|
||||
Check if there is currently a cashup in progress (CASHUP_START without corresponding CASHUP).
|
||||
Returns the CASHUP_START action if in progress, undef otherwise.
|
||||
|
||||
=cut
|
||||
|
||||
sub cashup_in_progress {
|
||||
my ($self) = @_;
|
||||
|
||||
my $last_start = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{ 'code' => 'CASHUP_START' },
|
||||
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
|
||||
)->single;
|
||||
|
||||
return unless $last_start;
|
||||
|
||||
my $last_completion = $self->cashups(
|
||||
{},
|
||||
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
|
||||
)->single;
|
||||
|
||||
# If we have a start but no completion, or the start is more recent than completion
|
||||
if ( !$last_completion
|
||||
|| DateTime->compare( dt_from_string( $last_start->timestamp ), dt_from_string( $last_completion->timestamp ) )
|
||||
> 0 )
|
||||
{
|
||||
return Koha::Cash::Register::Action->_new_from_dbic($last_start);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
=head3 store
|
||||
|
||||
Local store method to prevent direct manipulation of the 'branch_default' field
|
||||
|
|
@ -223,6 +240,89 @@ sub drop_default {
|
|||
return $self;
|
||||
}
|
||||
|
||||
=head3 start_cashup
|
||||
|
||||
my $cashup_start = $cash_register->start_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
}
|
||||
);
|
||||
|
||||
Start a new cashup period. This marks the beginning of the cash counting process
|
||||
and creates a snapshot point for calculating outstanding amounts. Returns the
|
||||
CASHUP_START action.
|
||||
|
||||
=cut
|
||||
|
||||
sub start_cashup {
|
||||
my ( $self, $params ) = @_;
|
||||
|
||||
# check for mandatory params
|
||||
my @mandatory = ('manager_id');
|
||||
for my $param (@mandatory) {
|
||||
unless ( defined( $params->{$param} ) ) {
|
||||
Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
|
||||
}
|
||||
}
|
||||
my $manager_id = $params->{manager_id};
|
||||
|
||||
# Check if there's already a cashup in progress
|
||||
my $last_cashup_start_rs = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{ 'code' => 'CASHUP_START' },
|
||||
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
|
||||
)->single;
|
||||
|
||||
my $last_cashup_completed = $self->cashups(
|
||||
{},
|
||||
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
|
||||
)->single;
|
||||
|
||||
# If we have a CASHUP_START that's more recent than the last CASHUP, there's already an active cashup
|
||||
if (
|
||||
$last_cashup_start_rs
|
||||
&& (
|
||||
!$last_cashup_completed || DateTime->compare(
|
||||
dt_from_string( $last_cashup_start_rs->timestamp ),
|
||||
dt_from_string( $last_cashup_completed->timestamp )
|
||||
) > 0
|
||||
)
|
||||
)
|
||||
{
|
||||
Koha::Exceptions::Object::DuplicateID->throw( error => "A cashup is already in progress for this register" );
|
||||
}
|
||||
|
||||
my $expected_amount = $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1;
|
||||
|
||||
# Prevent starting a cashup when there are no transactions at all
|
||||
my $total_transactions = $self->outstanding_accountlines->total() * -1;
|
||||
unless ( $total_transactions != 0 ) {
|
||||
Koha::Exceptions::Object::BadValue->throw(
|
||||
error => "Cannot start cashup with no transactions",
|
||||
type => 'amount',
|
||||
value => $total_transactions
|
||||
);
|
||||
}
|
||||
|
||||
# Create the CASHUP_START action, translating raw DBIC exceptions
|
||||
my $schema = $self->_result->result_source->schema;
|
||||
my $rs;
|
||||
try {
|
||||
$rs = $self->_result->add_to_cash_register_actions(
|
||||
{
|
||||
code => 'CASHUP_START',
|
||||
manager_id => $manager_id,
|
||||
amount => $expected_amount
|
||||
}
|
||||
)->discard_changes;
|
||||
} catch {
|
||||
$schema->translate_exception($_) if ref($_) eq 'DBIx::Class::Exception';
|
||||
$_->rethrow();
|
||||
};
|
||||
|
||||
return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
|
||||
}
|
||||
|
||||
=head3 add_cashup
|
||||
|
||||
my $cashup = $cash_register->add_cashup(
|
||||
|
|
@ -233,33 +333,103 @@ sub drop_default {
|
|||
}
|
||||
);
|
||||
|
||||
Add a new cashup action to the till, returns the added action.
|
||||
If amount differs from expected amount, creates surplus/deficit accountlines.
|
||||
Complete a cashup period started with start_cashup(). This performs the actual
|
||||
reconciliation against the amount counted and creates surplus/deficit accountlines
|
||||
if needed. Returns the completed CASHUP action.
|
||||
|
||||
=cut
|
||||
|
||||
sub add_cashup {
|
||||
my ( $self, $params ) = @_;
|
||||
|
||||
my $manager_id = $params->{manager_id};
|
||||
my $amount = $params->{amount};
|
||||
my $reconciliation_note = $params->{reconciliation_note};
|
||||
# check for mandatory params
|
||||
my @mandatory = ( 'manager_id', 'amount' );
|
||||
for my $param (@mandatory) {
|
||||
unless ( defined( $params->{$param} ) ) {
|
||||
Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
|
||||
}
|
||||
}
|
||||
my $manager_id = $params->{manager_id};
|
||||
|
||||
# Validate amount is a valid number
|
||||
my $amount = $params->{amount};
|
||||
unless ( looks_like_number($amount) ) {
|
||||
Koha::Exceptions::Account::AmountNotPositive->throw( error => 'Cashup amount must be a valid number' );
|
||||
}
|
||||
|
||||
# Sanitize reconciliation note - treat empty/whitespace-only as undef
|
||||
my $reconciliation_note = $params->{reconciliation_note};
|
||||
if ( defined $reconciliation_note ) {
|
||||
$reconciliation_note = substr( $reconciliation_note, 0, 1000 ); # Limit length
|
||||
$reconciliation_note =~ s/^\s+|\s+$//g; # Trim whitespace
|
||||
$reconciliation_note = undef if $reconciliation_note eq ''; # Empty after trim = undef
|
||||
}
|
||||
|
||||
# Calculate expected amount from outstanding accountlines
|
||||
my $expected_amount = $self->outstanding_accountlines->total;
|
||||
# Find the most recent CASHUP_START to determine if we're in two-phase mode
|
||||
my $cashup_start;
|
||||
my $cashup_start_rs = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{ 'code' => 'CASHUP_START' },
|
||||
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
|
||||
)->single;
|
||||
|
||||
# For backward compatibility, if no actual amount is specified, use expected amount
|
||||
$amount //= abs($expected_amount);
|
||||
if ($cashup_start_rs) {
|
||||
|
||||
# Two-phase mode: Check if this CASHUP_START has already been completed
|
||||
my $existing_completion = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{
|
||||
'code' => 'CASHUP',
|
||||
'timestamp' => { '>' => $cashup_start_rs->timestamp }
|
||||
},
|
||||
{ rows => 1 }
|
||||
)->single;
|
||||
|
||||
if ( !$existing_completion ) {
|
||||
$cashup_start = Koha::Cash::Register::Cashup->_new_from_dbic($cashup_start_rs);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# Calculate expected amount from session accountlines
|
||||
my $expected_amount = (
|
||||
$cashup_start
|
||||
? $cashup_start->accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
|
||||
: $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
|
||||
) * -1;
|
||||
|
||||
# Calculate difference (actual - expected)
|
||||
my $difference = $amount - abs($expected_amount);
|
||||
my $difference = $amount - $expected_amount;
|
||||
|
||||
# Validate reconciliation note requirement if there's a discrepancy
|
||||
if ( $difference != 0 ) {
|
||||
my $note_required = C4::Context->preference('CashupReconciliationNoteRequired') // 0;
|
||||
|
||||
if ( $note_required && !defined $reconciliation_note ) {
|
||||
Koha::Exceptions::MissingParameter->throw(
|
||||
error => "Reconciliation note is required when cashup amount differs from expected amount" );
|
||||
}
|
||||
|
||||
# If notes are restricted to an authorised value category, reject anything
|
||||
# that isn't one of the allowed values (the UI only offers a matching
|
||||
# select, but the API/form param isn't otherwise constrained server-side)
|
||||
my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
|
||||
if ( $note_av_category && defined $reconciliation_note ) {
|
||||
require Koha::AuthorisedValues;
|
||||
my $valid = Koha::AuthorisedValues->search(
|
||||
{
|
||||
category => $note_av_category,
|
||||
authorised_value => $reconciliation_note,
|
||||
}
|
||||
)->count;
|
||||
unless ($valid) {
|
||||
Koha::Exceptions::BadParameter->throw(
|
||||
error => "Reconciliation note is not a valid authorised value for this category",
|
||||
parameter => 'reconciliation_note',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Use database transaction to ensure consistency
|
||||
my $schema = $self->_result->result_source->schema;
|
||||
|
|
@ -267,82 +437,185 @@ sub add_cashup {
|
|||
|
||||
$schema->txn_do(
|
||||
sub {
|
||||
# Create the cashup action with actual amount
|
||||
my $rs = $self->_result->add_to_cash_register_actions(
|
||||
{
|
||||
code => 'CASHUP',
|
||||
manager_id => $manager_id,
|
||||
amount => $amount
|
||||
try {
|
||||
# Create the cashup action (raw DBIC call; Koha::Object stores below are already translated)
|
||||
my $rs = $self->_result->add_to_cash_register_actions(
|
||||
{
|
||||
code => 'CASHUP',
|
||||
manager_id => $manager_id,
|
||||
amount => $amount
|
||||
}
|
||||
)->discard_changes;
|
||||
$cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
|
||||
|
||||
# Create reconciliation accountline if there's a difference
|
||||
if ( $difference != 0 ) {
|
||||
|
||||
# Determine reconciliation date based on mode
|
||||
my $reconciliation_date;
|
||||
if ($cashup_start) {
|
||||
|
||||
# Two-phase mode: Backdate reconciliation lines to just before the CASHUP_START timestamp
|
||||
# This ensures they belong to the previous session, not the current one
|
||||
$reconciliation_date = \[ 'DATE_SUB(?, INTERVAL 1 SECOND)', $cashup_start->timestamp ];
|
||||
} else {
|
||||
|
||||
# Legacy mode: Use the original backdating approach
|
||||
$reconciliation_date = \'DATE_SUB(NOW(), INTERVAL 1 SECOND)';
|
||||
}
|
||||
|
||||
if ( $difference > 0 ) {
|
||||
|
||||
# Surplus: more cash found than expected (credits are negative amounts)
|
||||
my $surplus = Koha::Account::Line->new(
|
||||
{
|
||||
date => $reconciliation_date,
|
||||
amount => -abs($difference), # Credits are negative
|
||||
amountoutstanding => 0,
|
||||
credit_type_code => 'CASHUP_SURPLUS',
|
||||
manager_id => $manager_id,
|
||||
interface => 'intranet',
|
||||
branchcode => $self->branch,
|
||||
register_id => $self->id,
|
||||
payment_type => 'CASH',
|
||||
note => $reconciliation_note
|
||||
}
|
||||
)->store();
|
||||
|
||||
# Record the account offset
|
||||
my $account_offset = Koha::Account::Offset->new(
|
||||
{
|
||||
credit_id => $surplus->id,
|
||||
type => 'CREATE',
|
||||
amount => -abs($difference) # Offsets match the line amount
|
||||
}
|
||||
)->store();
|
||||
|
||||
} else {
|
||||
|
||||
# Deficit: less cash found than expected
|
||||
my $deficit = Koha::Account::Line->new(
|
||||
{
|
||||
date => $reconciliation_date,
|
||||
amount => abs($difference),
|
||||
amountoutstanding => 0,
|
||||
debit_type_code => 'CASHUP_DEFICIT',
|
||||
manager_id => $manager_id,
|
||||
interface => 'intranet',
|
||||
branchcode => $self->branch,
|
||||
register_id => $self->id,
|
||||
payment_type => 'CASH',
|
||||
note => $reconciliation_note
|
||||
}
|
||||
)->store();
|
||||
my $account_offset = Koha::Account::Offset->new(
|
||||
{
|
||||
debit_id => $deficit->id,
|
||||
type => 'CREATE',
|
||||
amount => abs($difference) # Debits have positive offsets
|
||||
}
|
||||
)->store();
|
||||
|
||||
}
|
||||
}
|
||||
)->discard_changes;
|
||||
|
||||
$cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
|
||||
|
||||
# Create reconciliation accountline if there's a difference
|
||||
if ( $difference != 0 ) {
|
||||
|
||||
if ( $difference > 0 ) {
|
||||
|
||||
# Surplus: more cash found than expected (credits are negative amounts)
|
||||
my $surplus = Koha::Account::Line->new(
|
||||
{
|
||||
date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
|
||||
amount => -abs($difference), # Credits are negative
|
||||
amountoutstanding => 0,
|
||||
description => 'Cash register surplus found during cashup',
|
||||
credit_type_code => 'CASHUP_SURPLUS',
|
||||
payment_type => 'CASH',
|
||||
manager_id => $manager_id,
|
||||
interface => 'intranet',
|
||||
branchcode => $self->branch,
|
||||
register_id => $self->id,
|
||||
note => $reconciliation_note
|
||||
}
|
||||
)->store();
|
||||
|
||||
# Record the account offset
|
||||
my $account_offset = Koha::Account::Offset->new(
|
||||
{
|
||||
credit_id => $surplus->id,
|
||||
type => 'CREATE',
|
||||
amount => -abs($difference) # Offsets match the line amount
|
||||
}
|
||||
)->store();
|
||||
|
||||
} else {
|
||||
|
||||
# Deficit: less cash found than expected
|
||||
my $deficit = Koha::Account::Line->new(
|
||||
{
|
||||
date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
|
||||
amount => abs($difference),
|
||||
amountoutstanding => 0,
|
||||
description => 'Cash register deficit found during cashup',
|
||||
debit_type_code => 'CASHUP_DEFICIT',
|
||||
payment_type => 'CASH',
|
||||
manager_id => $manager_id,
|
||||
interface => 'intranet',
|
||||
branchcode => $self->branch,
|
||||
register_id => $self->id,
|
||||
note => $reconciliation_note
|
||||
}
|
||||
)->store();
|
||||
my $account_offset = Koha::Account::Offset->new(
|
||||
{
|
||||
debit_id => $deficit->id,
|
||||
type => 'CREATE',
|
||||
amount => abs($difference) # Debits have positive offsets
|
||||
}
|
||||
)->store();
|
||||
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$schema->translate_exception($_) if ref($_) eq 'DBIx::Class::Exception';
|
||||
$_->rethrow();
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return $cashup;
|
||||
}
|
||||
|
||||
=head3 _get_session_start_timestamp
|
||||
|
||||
Internal method to determine the start timestamp for the current "open" session.
|
||||
This handles the following cashup scenarios:
|
||||
|
||||
=over 4
|
||||
|
||||
=item 1. No cashups ever → undef (returns all accountlines)
|
||||
|
||||
=item 2. Quick cashup completed → Uses CASHUP timestamp
|
||||
|
||||
=item 3. Two-phase started → Uses CASHUP_START timestamp
|
||||
|
||||
=item 4. Two-phase completed → Uses the CASHUP_START timestamp that led to the last CASHUP
|
||||
|
||||
=item 5. Mixed workflows → Correctly distinguishes between quick and two-phase cashups
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
sub _get_session_start_timestamp {
|
||||
my ($self) = @_;
|
||||
|
||||
# Check if there's a cashup in progress (CASHUP_START without corresponding CASHUP)
|
||||
my $cashup_in_progress = $self->cashup_in_progress;
|
||||
|
||||
if ($cashup_in_progress) {
|
||||
|
||||
# Scenario 3: Two-phase cashup started - return accountlines since CASHUP_START
|
||||
return $cashup_in_progress->timestamp;
|
||||
}
|
||||
|
||||
# No cashup in progress - find the most recent cashup completion
|
||||
my $last_cashup = $self->cashups(
|
||||
{},
|
||||
{
|
||||
order_by => { '-desc' => [ 'timestamp', 'id' ] },
|
||||
rows => 1
|
||||
}
|
||||
)->single;
|
||||
|
||||
if ( !$last_cashup ) {
|
||||
|
||||
# Scenario 1: No cashups have ever taken place - return all accountlines
|
||||
return;
|
||||
}
|
||||
|
||||
# Find if this CASHUP was part of a two-phase workflow
|
||||
my $corresponding_start = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{
|
||||
'code' => 'CASHUP_START',
|
||||
'timestamp' => { '<' => $last_cashup->timestamp }
|
||||
},
|
||||
{
|
||||
order_by => { '-desc' => [ 'timestamp', 'id' ] },
|
||||
rows => 1
|
||||
}
|
||||
)->single;
|
||||
|
||||
if ($corresponding_start) {
|
||||
|
||||
# Check if this CASHUP_START was completed by this CASHUP
|
||||
# (no other CASHUP between them)
|
||||
my $intervening_cashup = $self->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{
|
||||
'code' => 'CASHUP',
|
||||
'timestamp' => {
|
||||
'>' => $corresponding_start->timestamp,
|
||||
'<' => $last_cashup->timestamp
|
||||
}
|
||||
},
|
||||
{ rows => 1 }
|
||||
)->single;
|
||||
|
||||
if ( !$intervening_cashup ) {
|
||||
|
||||
# Scenario 4: Two-phase cashup completed - return accountlines since the CASHUP_START
|
||||
return $corresponding_start->timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
# Scenarios 2 & 5: Quick cashup (or orphaned CASHUP) - return accountlines since CASHUP
|
||||
return $last_cashup->timestamp;
|
||||
}
|
||||
|
||||
=head3 to_api_mapping
|
||||
|
||||
This method returns the mapping for representing a Koha::Cash::Register object
|
||||
|
|
|
|||
|
|
@ -61,23 +61,29 @@ Return a hashref containing a summary of transactions that make up this cashup.
|
|||
sub summary {
|
||||
my ($self) = @_;
|
||||
my $summary;
|
||||
my $prior_cashup = Koha::Cash::Register::Cashups->search(
|
||||
{
|
||||
'timestamp' => { '<' => $self->timestamp },
|
||||
register_id => $self->register_id
|
||||
},
|
||||
{
|
||||
order_by => { '-desc' => [ 'timestamp', 'id' ] },
|
||||
rows => 1
|
||||
}
|
||||
);
|
||||
|
||||
my $previous = $prior_cashup->single;
|
||||
# Get the session boundaries for this cashup
|
||||
my ( $session_start, $session_end ) = $self->_get_session_boundaries;
|
||||
|
||||
my $conditions =
|
||||
$previous
|
||||
? { 'date' => { '-between' => [ $previous->_result->get_column('timestamp'), $self->timestamp ] } }
|
||||
: { 'date' => { '<' => $self->timestamp } };
|
||||
my $conditions;
|
||||
if ( $session_start && $session_end ) {
|
||||
|
||||
# Complete session: between start and end (exclusive)
|
||||
$conditions = {
|
||||
'date' => {
|
||||
'>' => $session_start,
|
||||
'<' => $session_end
|
||||
}
|
||||
};
|
||||
} elsif ($session_end) {
|
||||
|
||||
# Session from beginning to end
|
||||
$conditions = { 'date' => { '<' => $session_end } };
|
||||
} else {
|
||||
|
||||
# Shouldn't happen for a completed cashup, but fallback
|
||||
$conditions = { 'date' => { '<' => $self->timestamp } };
|
||||
}
|
||||
|
||||
my $payout_transactions = $self->register->accountlines->search(
|
||||
{
|
||||
|
|
@ -198,8 +204,8 @@ sub summary {
|
|||
my $deficit_note = $deficit_record ? $deficit_record->note : undef;
|
||||
|
||||
$summary = {
|
||||
from_date => $previous ? $previous->timestamp : undef,
|
||||
to_date => $self->timestamp,
|
||||
from_date => $session_start,
|
||||
to_date => $session_end,
|
||||
income_grouped => \@income,
|
||||
income_total => abs($income_total),
|
||||
payout_grouped => \@payout,
|
||||
|
|
@ -217,6 +223,110 @@ sub summary {
|
|||
return $summary;
|
||||
}
|
||||
|
||||
=head3 accountlines
|
||||
|
||||
Fetch the accountlines associated with this cashup
|
||||
|
||||
=cut
|
||||
|
||||
sub accountlines {
|
||||
my ($self) = @_;
|
||||
|
||||
# Get the session boundaries for this cashup
|
||||
my ( $session_start, $session_end ) = $self->_get_session_boundaries;
|
||||
|
||||
my $conditions;
|
||||
if ( $session_start && $session_end ) {
|
||||
|
||||
# Complete session: between start and end (exclusive)
|
||||
$conditions = {
|
||||
'date' => {
|
||||
'>' => $session_start,
|
||||
'<' => $session_end
|
||||
}
|
||||
};
|
||||
} elsif ($session_end) {
|
||||
|
||||
# Session from beginning to end
|
||||
$conditions = { 'date' => { '<' => $session_end } };
|
||||
} else {
|
||||
|
||||
# Shouldn't happen for a completed cashup, but fallback
|
||||
$conditions = { 'date' => { '<' => $self->timestamp } };
|
||||
}
|
||||
|
||||
return $self->register->accountlines->search($conditions);
|
||||
}
|
||||
|
||||
=head3 _get_session_boundaries
|
||||
|
||||
Internal method to determine the session boundaries for this cashup.
|
||||
Returns ($session_start, $session_end) timestamps.
|
||||
|
||||
=cut
|
||||
|
||||
sub _get_session_boundaries {
|
||||
my ($self) = @_;
|
||||
|
||||
my $session_end = $self->_get_session_end;
|
||||
|
||||
# Find the previous CASHUP
|
||||
my $session_start;
|
||||
my $previous_cashup = $self->register->cashups(
|
||||
{ 'timestamp' => { '<' => $session_end } },
|
||||
{
|
||||
order_by => { '-desc' => [ 'timestamp', 'id' ] },
|
||||
rows => 1
|
||||
}
|
||||
)->single;
|
||||
|
||||
$session_start = $previous_cashup ? $previous_cashup->_get_session_end : undef;
|
||||
|
||||
return ( $session_start, $session_end );
|
||||
}
|
||||
|
||||
sub _get_session_end {
|
||||
my ($self) = @_;
|
||||
|
||||
my $session_end = $self->timestamp;
|
||||
|
||||
# Find if this CASHUP was part of a two-phase workflow
|
||||
my $nearest_start = $self->register->_result->search_related(
|
||||
'cash_register_actions',
|
||||
{
|
||||
'code' => 'CASHUP_START',
|
||||
'timestamp' => { '<' => $session_end }
|
||||
},
|
||||
{
|
||||
order_by => { '-desc' => [ 'timestamp', 'id' ] },
|
||||
rows => 1
|
||||
}
|
||||
)->single;
|
||||
|
||||
if ($nearest_start) {
|
||||
|
||||
# Check if this CASHUP_START was completed by this CASHUP
|
||||
# (no other CASHUP between them)
|
||||
my $intervening_cashup = $self->register->cashups(
|
||||
{
|
||||
'timestamp' => {
|
||||
'>' => $nearest_start->timestamp,
|
||||
'<' => $session_end
|
||||
}
|
||||
},
|
||||
{ rows => 1 }
|
||||
)->single;
|
||||
|
||||
if ( !$intervening_cashup ) {
|
||||
|
||||
# Two-phase workflow: session runs to CASHUP_START
|
||||
$session_end = $nearest_start->timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return $session_end;
|
||||
}
|
||||
|
||||
=head3 to_api_mapping
|
||||
|
||||
This method returns the mapping for representing a Koha::Cash::Register::Cashup object
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
[% USE raw %]
|
||||
<!-- Confirm cashup modal -->
|
||||
<!-- Parameters:
|
||||
- modal_id: ID for the modal (required)
|
||||
- reconciliation_note_avs: Authorized values for notes (optional)
|
||||
- reconciliation_note_required: Whether note is required (optional)
|
||||
- cashup_in_progress: Whether completing in-progress cashup (optional)
|
||||
- form_action: Form action URL (optional, defaults to current page)
|
||||
- redirect_to: Where to redirect after completion (optional: 'register' or 'registers')
|
||||
-->
|
||||
<div class="modal" id="[% modal_id | html %]" tabindex="-1" role="dialog" aria-labelledby="[% modal_id | html %]Label">
|
||||
<form method="post" enctype="multipart/form-data" class="validated confirm-cashup-form" [% IF form_action %]action="[% form_action | html %]"[% END %]>
|
||||
[% INCLUDE 'csrf-token.inc' %]
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title" id="[% modal_id | html %]Label">
|
||||
[% IF cashup_in_progress %]
|
||||
Complete cashup of <em><span class="register-name">[% register.description | html %]</span></em>
|
||||
[% ELSE %]
|
||||
Confirm cashup of <em><span class="register-name"></span></em>
|
||||
[% END %]
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<fieldset class="rows">
|
||||
<ol>
|
||||
<li>
|
||||
<span class="label expected-amount-label">
|
||||
[% IF cashup_in_progress %]
|
||||
[% IF cashup_in_progress.amount < 0 %]
|
||||
Expected amount to add:
|
||||
[% ELSE %]
|
||||
Expected cashup amount:
|
||||
[% END %]
|
||||
[% ELSE %]
|
||||
Expected cashup amount:
|
||||
[% END %]
|
||||
</span>
|
||||
<span class="expected-amount">[% IF cashup_in_progress %][% cashup_in_progress.amount | $Price %][% END %]</span>
|
||||
</li>
|
||||
<li>
|
||||
<label class="required actual-amount-label" for="cashup_amount">
|
||||
[% IF cashup_in_progress %]
|
||||
[% IF cashup_in_progress.amount < 0 %]
|
||||
Actual amount added to register:
|
||||
[% ELSE %]
|
||||
Actual cashup amount counted:
|
||||
[% END %]
|
||||
[% ELSE %]
|
||||
Actual cashup amount counted:
|
||||
[% END %]
|
||||
</label>
|
||||
<input type="text" inputmode="decimal" pattern="^-?\d+(\.\d{2})?$" id="cashup_amount" name="amount" class="cashup-amount-input" required="required" />
|
||||
<span class="required">Required</span>
|
||||
</li>
|
||||
<li class="reconciliation-display" style="display: none;">
|
||||
<span class="label">Reconciliation:</span>
|
||||
<span class="reconciliation-text"></span>
|
||||
</li>
|
||||
<li class="reconciliation-note-field" style="display: none;">
|
||||
<label class="reconciliation-note-label" for="cashup_reconciliation_note"> Note[% IF reconciliation_note_required %](required)[% ELSE %](optional)[% END %]: </label>
|
||||
[% IF reconciliation_note_avs %]
|
||||
<select id="cashup_reconciliation_note" class="reconciliation-note-input" name="reconciliation_note">
|
||||
<option value="">-- Select a reason --</option>
|
||||
[% FOREACH av IN reconciliation_note_avs %]
|
||||
<option value="[% av.authorised_value | html %]">[% av.lib | html %]</option>
|
||||
[% END %]
|
||||
</select>
|
||||
[% IF reconciliation_note_required %]
|
||||
<span class="required">Required</span>
|
||||
[% END %]
|
||||
[% ELSE %]
|
||||
<textarea
|
||||
id="cashup_reconciliation_note"
|
||||
class="reconciliation-note-input"
|
||||
name="reconciliation_note"
|
||||
rows="3"
|
||||
cols="40"
|
||||
maxlength="1000"
|
||||
placeholder="Enter a note explaining the surplus or deficit..."
|
||||
></textarea>
|
||||
[% IF reconciliation_note_required %]
|
||||
<span class="required">Required</span>
|
||||
[% END %]
|
||||
<div class="hint">Maximum 1000 characters</div>
|
||||
[% END %]
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="registerid" class="register-id-field" value="[% IF cashup_in_progress %][% register.id | html %][% END %]" />
|
||||
<input type="hidden" name="op" value="cud-cashup" />
|
||||
[% IF redirect_to %]
|
||||
<input type="hidden" name="redirect_to" value="[% redirect_to | html %]" />
|
||||
[% END %]
|
||||
<button type="submit" class="btn btn-primary"> [% IF cashup_in_progress %]Complete cashup[% ELSE %]Confirm cashup[% END %] </button>
|
||||
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- /#[% modal_id | html %] -->
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
[% USE raw %]
|
||||
<!-- Trigger cashup modal -->
|
||||
<!-- Parameters:
|
||||
- modal_id: ID for the modal (required)
|
||||
- register_description: Register description to pre-populate (optional, for register.tt)
|
||||
- register_id: Register ID to pre-populate (optional, for register.tt)
|
||||
- form_action: Form action URL (optional, defaults to current page)
|
||||
- redirect_to: Where to redirect after completion (optional: 'register' or 'registers')
|
||||
-->
|
||||
<div class="modal" id="[% modal_id | html %]" tabindex="-1" role="dialog" aria-labelledby="[% modal_id | html %]Label">
|
||||
<form method="post" class="validated trigger-cashup-form" [% IF form_action %]action="[% form_action | html %]"[% END %]>
|
||||
[% INCLUDE 'csrf-token.inc' %]
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title" id="[% modal_id | html %]Label">
|
||||
Cashup for <em><span class="register-description">[% IF register_description %][% register_description | html %][% END %]</span></em>
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p><strong>Choose how to proceed with the cashup:</strong></p>
|
||||
<p><strong>Start cashup</strong></p>
|
||||
<ul class="start-cashup-instructions">
|
||||
<!-- JavaScript will populate this based on positive/negative amount -->
|
||||
</ul>
|
||||
<p><strong>Quick cashup</strong></p>
|
||||
<ul class="quick-cashup-instructions">
|
||||
<!-- JavaScript will populate this based on positive/negative amount -->
|
||||
</ul>
|
||||
<p class="float-reminder-text">
|
||||
<!-- JavaScript will populate this based on positive/negative amount -->
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="registerid" class="register-id-field" value="[% IF register_id %][% register_id | html %][% END %]" />
|
||||
<input type="hidden" name="op" value="cud-cashup_start" />
|
||||
<input type="hidden" name="amount" value="" />
|
||||
[% IF redirect_to %]
|
||||
<input type="hidden" name="redirect_to" value="[% redirect_to | html %]" />
|
||||
[% END %]
|
||||
<button type="submit" class="btn btn-primary">Start cashup</button>
|
||||
<button type="button" class="btn btn-success quick-cashup-btn">Quick cashup</button>
|
||||
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- /#[% modal_id | html %] -->
|
||||
|
|
@ -52,6 +52,47 @@
|
|||
<div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_in_progress ) %]
|
||||
<div id="error_message" class="alert alert-warning"> A cashup is already in progress for this register. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_no_transactions ) %]
|
||||
<div id="error_message" class="alert alert-info"> Cannot start cashup - there are no transactions in this register since the last cashup. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_no_cashup_start ) %]
|
||||
<div id="error_message" class="alert alert-warning"> No cashup session has been started. Please start a cashup before attempting to complete it. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_already_completed ) %]
|
||||
<div id="error_message" class="alert alert-warning"> This cashup session has already been completed. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_start ) %]
|
||||
<div id="error_message" class="alert alert-warning"> Failed to start cashup. Please try again. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_missing_param ) %]
|
||||
<div id="error_message" class="alert alert-warning"> Missing required parameter for cashup: [% error_message | html %] </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_amount_invalid ) %]
|
||||
<div id="error_message" class="alert alert-warning"> The cashup amount must be a valid number. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_reconciliation_note_required ) %]
|
||||
<div id="error_message" class="alert alert-warning"> Reconciliation note is required when cashup amount differs from expected amount. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_complete ) %]
|
||||
<div id="error_message" class="alert alert-warning">
|
||||
Failed to complete cashup. Please try again.
|
||||
[% IF error_details %]
|
||||
<br /><strong>Error details:</strong> [% error_details | html %]
|
||||
[% END %]
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_refund_permission ) %]
|
||||
<div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
|
||||
[% END %]
|
||||
|
|
@ -60,9 +101,28 @@
|
|||
<div id="error_message" class="alert alert-warning"> Account credit refunds are not available for anonymous transactions. Please choose a different refund type. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF cashup_in_progress %]
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa-solid fa-info-circle"></i>
|
||||
[% SET progress_timestamp = cashup_in_progress.timestamp | $KohaDates(with_hours => 1) %]
|
||||
[% tx("Cashup in progress - started {timestamp}. You can continue to make transactions while counting cash.", { timestamp = progress_timestamp }) | html %]
|
||||
(<a data-bs-toggle="modal" data-cashup="[% cashup_in_progress.id | html %]" data-register="[% register.description | html %]" data-in-progress="true" href="#cashupSummaryModal" class="button"
|
||||
>[% t("Preview cashup summary") | html %]</a
|
||||
>)
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% SET total_bankable = accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 %]
|
||||
[% SET total_transactions = accountlines.total() * -1 %]
|
||||
[% IF ( CAN_user_cash_management_cashup ) %]
|
||||
<div id="toolbar" class="btn-toolbar">
|
||||
<button id="pos_cashup" type="button" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupModal"><i class="fa-solid fa-money-bill-1"></i> Record cashup</button>
|
||||
[% IF cashup_in_progress %]
|
||||
<button id="pos_complete_cashup" type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#confirmCashupModal"> <i class="fa-solid fa-check"></i> Complete cashup </button>
|
||||
[% ELSE %]
|
||||
<button type="button" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#triggerCashupModal" [% IF total_transactions == 0 %]disabled title="No transactions"[% END %]>
|
||||
<i class="fa fa-money-bill-alt"></i> Record cashup
|
||||
</button>
|
||||
[% END %]
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
|
|
@ -85,7 +145,7 @@
|
|||
<li>Float: [% register.starting_float | $Price %]</li>
|
||||
<li>Total income (cash): [% accountlines.credits_total * -1 | $Price %] ([% accountlines.credits_total(payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li>
|
||||
<li>Total outgoing (cash): [% accountlines.debits_total * -1 | $Price %] ([% accountlines.debits_total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li>
|
||||
<li>Total bankable: [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</li>
|
||||
<li>Total bankable: [% total_bankable | $Price %]</li>
|
||||
</ul>
|
||||
|
||||
[% IF register.last_cashup %]
|
||||
|
|
@ -372,60 +432,6 @@
|
|||
[% END %]
|
||||
[% END %]
|
||||
|
||||
<!-- Confirm cashup modal -->
|
||||
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
|
||||
<form id="cashup_form" method="post" enctype="multipart/form-data" class="validated">
|
||||
[% INCLUDE 'csrf-token.inc' %]
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title" id="confirmCashupLabel">Confirm cashup of <em>[% register.description | html %]</em></h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<fieldset class="rows">
|
||||
<ol>
|
||||
<li>
|
||||
<span class="label">Expected amount to remove:</span>
|
||||
<span id="expected_amount" class="expected-amount">[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">Float to remain:</span>
|
||||
<span>[% register.starting_float | $Price %]</span>
|
||||
</li>
|
||||
<li>
|
||||
<label class="required" for="amount">Actual amount removed from register:</label>
|
||||
<input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" />
|
||||
<span class="required">Required</span>
|
||||
</li>
|
||||
<li id="reconciliation_display" style="display: none;">
|
||||
<span class="label">Reconciliation:</span>
|
||||
<span id="reconciliation_text"></span>
|
||||
</li>
|
||||
<li id="reconciliation_note_field" style="display: none;">
|
||||
<label for="reconciliation_note">Note (optional):</label>
|
||||
<textarea id="reconciliation_note" name="reconciliation_note" rows="3" cols="40" maxlength="1000" placeholder="Enter a note explaining the surplus or deficit..."></textarea>
|
||||
<div class="hint">Maximum 1000 characters</div>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div>
|
||||
<!-- /.modal-body -->
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="registerid" value="[% register.id | html %]" />
|
||||
<input type="hidden" name="op" value="cud-cashup" />
|
||||
<button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button>
|
||||
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
<!-- /.modal-footer -->
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- /#confirmCashupModal -->
|
||||
|
||||
<!-- Issue refund modal -->
|
||||
<div class="modal" id="issueRefundModal" tabindex="-1" role="dialog" aria-labelledby="issueRefundLabel">
|
||||
<form id="refund_form" method="post" enctype="multipart/form-data" class="validated">
|
||||
|
|
@ -470,12 +476,15 @@
|
|||
<!-- /#issueRefundModal -->
|
||||
|
||||
[% INCLUDE 'modals/cashup_summary.inc' %]
|
||||
[% INCLUDE 'modals/trigger_cashup.inc' modal_id='triggerCashupModal' register_description=register.description register_id=register.id %]
|
||||
[% INCLUDE 'modals/confirm_cashup.inc' modal_id='confirmCashupModal' reconciliation_note_avs=reconciliation_note_avs reconciliation_note_required=reconciliation_note_required cashup_in_progress=cashup_in_progress %]
|
||||
|
||||
[% MACRO jsinclude BLOCK %]
|
||||
[% INCLUDE 'datatables.inc' %]
|
||||
[% INCLUDE 'format_price.inc' %]
|
||||
[% INCLUDE 'js-date-format.inc' %]
|
||||
[% Asset.js("js/cashup_modal.js") | $raw %]
|
||||
[% Asset.js("js/modals/cashup_modals.js") | $raw %]
|
||||
[% Asset.js("js/modal_printer.js") | $raw %]
|
||||
[% INCLUDE 'calendar.inc' %]
|
||||
<script nonce="[% Koha.CSPNonce | $raw %]">
|
||||
|
|
@ -640,57 +649,16 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Real-time reconciliation calculation for cashup modal
|
||||
$("#amount").on("input", function() {
|
||||
var actualAmount = parseFloat($(this).val()) || 0;
|
||||
var expectedText = $("#expected_amount").text().replace(/[£$,]/g, '');
|
||||
var expectedAmount = parseFloat(expectedText) || 0;
|
||||
var difference = actualAmount - expectedAmount;
|
||||
|
||||
if ($(this).val() && !isNaN(actualAmount)) {
|
||||
var reconciliationText = "";
|
||||
var reconciliationClass = "";
|
||||
var hasDiscrepancy = false;
|
||||
|
||||
if (difference > 0) {
|
||||
reconciliationText = "Surplus: " + difference.format_price();
|
||||
reconciliationClass = "success";
|
||||
hasDiscrepancy = true;
|
||||
} else if (difference < 0) {
|
||||
reconciliationText = "Deficit: " + Math.abs(difference).format_price();
|
||||
reconciliationClass = "warning";
|
||||
hasDiscrepancy = true;
|
||||
} else {
|
||||
reconciliationText = "Balanced - no surplus or deficit";
|
||||
reconciliationClass = "success";
|
||||
hasDiscrepancy = false;
|
||||
}
|
||||
|
||||
$("#reconciliation_text").text(reconciliationText)
|
||||
.removeClass("success warning")
|
||||
.addClass(reconciliationClass);
|
||||
$("#reconciliation_display").show();
|
||||
|
||||
// Show/hide note field based on whether there's a discrepancy
|
||||
if (hasDiscrepancy) {
|
||||
$("#reconciliation_note_field").show();
|
||||
} else {
|
||||
$("#reconciliation_note_field").hide();
|
||||
$("#reconciliation_note").val(''); // Clear note when balanced
|
||||
}
|
||||
} else {
|
||||
$("#reconciliation_display").hide();
|
||||
$("#reconciliation_note_field").hide();
|
||||
}
|
||||
// Initialize cashup modals
|
||||
initTriggerCashupModal('#triggerCashupModal', {
|
||||
bankableAmount: [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | html %],
|
||||
registerFloat: [% register.starting_float | html %]
|
||||
});
|
||||
|
||||
// Reset modal when opened
|
||||
$("#confirmCashupModal").on("shown.bs.modal", function() {
|
||||
// Start with empty actual amount field (user must enter amount)
|
||||
$("#amount").val('').focus();
|
||||
$("#reconciliation_display").hide();
|
||||
$("#reconciliation_note_field").hide();
|
||||
$("#reconciliation_note").val('');
|
||||
initConfirmCashupModal('#confirmCashupModal', {
|
||||
hasAuthorisedValues: [% reconciliation_note_avs ? 'true' : 'false' | html %],
|
||||
noteRequired: [% reconciliation_note_required ? 'true' : 'false' | html %],
|
||||
isInProgress: [% cashup_in_progress ? 'true' : 'false' | html %]
|
||||
});
|
||||
</script>
|
||||
[% END %]
|
||||
|
|
|
|||
|
|
@ -45,9 +45,105 @@
|
|||
<div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF CAN_user_cash_management_cashup %]
|
||||
<div id="toolbar" class="btn-toolbar">
|
||||
<button type="button" class="cashup_all btn btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupAllModal"><i class="fa-solid fa-money-bill-1"></i> Cashup all</button>
|
||||
[% IF ( error_cashup_start && cashup_errors ) %]
|
||||
<div class="alert alert-warning">
|
||||
<strong>Some registers failed to start cashup:</strong>
|
||||
<ul>
|
||||
[% FOREACH error IN cashup_errors %]
|
||||
<li>[% error | html %]</li>
|
||||
[% END %]
|
||||
</ul>
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_complete && cashup_errors ) %]
|
||||
<div class="alert alert-warning">
|
||||
<strong>Some registers failed to complete cashup:</strong>
|
||||
<ul>
|
||||
[% FOREACH error IN cashup_errors %]
|
||||
<li>[% error | html %]</li>
|
||||
[% END %]
|
||||
</ul>
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( cashup_start_success ) %]
|
||||
<div class="alert alert-success">
|
||||
<i class="fa fa-check"></i>
|
||||
[% IF cashup_start_success == 1 %]
|
||||
Successfully started cashup for 1 register.
|
||||
[% ELSE %]
|
||||
Successfully started cashup for [% cashup_start_success | html %] registers.
|
||||
[% END %]
|
||||
[% IF ( cashup_start_errors ) %]
|
||||
[% IF cashup_start_errors == 1 %]
|
||||
However, 1 register had errors.
|
||||
[% ELSE %]
|
||||
However, [% cashup_start_errors | html %] registers had errors.
|
||||
[% END %]
|
||||
[% END %]
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( cashup_start_success && cashup_start_error_details ) %]
|
||||
<div class="alert alert-warning">
|
||||
<strong>The following registers had errors:</strong>
|
||||
<ul>
|
||||
[% FOREACH error IN cashup_start_error_details %]
|
||||
<li>[% error | html %]</li>
|
||||
[% END %]
|
||||
</ul>
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( cashup_complete_success ) %]
|
||||
<div class="alert alert-success">
|
||||
<i class="fa fa-check"></i>
|
||||
[% IF cashup_complete_success == 1 %]
|
||||
Successfully completed cashup for 1 register.
|
||||
[% ELSE %]
|
||||
Successfully completed cashup for [% cashup_complete_success | html %] registers.
|
||||
[% END %]
|
||||
[% IF ( cashup_complete_errors ) %]
|
||||
[% IF cashup_complete_errors == 1 %]
|
||||
However, 1 register had errors.
|
||||
[% ELSE %]
|
||||
However, [% cashup_complete_errors | html %] registers had errors.
|
||||
[% END %]
|
||||
[% END %]
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( cashup_complete_success && cashup_complete_error_details ) %]
|
||||
<div class="alert alert-warning">
|
||||
<strong>The following registers had errors:</strong>
|
||||
<ul>
|
||||
[% FOREACH error IN cashup_complete_error_details %]
|
||||
<li>[% error | html %]</li>
|
||||
[% END %]
|
||||
</ul>
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( cashup_start_errors && !cashup_start_success ) %]
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
[% IF cashup_start_errors == 1 %]
|
||||
Failed to start cashup for 1 register.
|
||||
[% ELSE %]
|
||||
Failed to start cashup for [% cashup_start_errors | html %] registers.
|
||||
[% END %]
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( cashup_complete_errors && !cashup_complete_success ) %]
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
[% IF cashup_complete_errors == 1 %]
|
||||
Failed to complete cashup for 1 register.
|
||||
[% ELSE %]
|
||||
Failed to complete cashup for [% cashup_complete_errors | html %] registers.
|
||||
[% END %]
|
||||
</div>
|
||||
[% END %]
|
||||
|
||||
|
|
@ -64,6 +160,9 @@
|
|||
<table id="registers" class="table_registers">
|
||||
<thead>
|
||||
<tr>
|
||||
[% IF CAN_user_cash_management_cashup %]
|
||||
<th class="no-export"><input type="checkbox" id="select_all_registers" title="Select all available registers" /></th>
|
||||
[% END %]
|
||||
<th>Register name</th>
|
||||
<th>Register description</th>
|
||||
<th>Last cashup</th>
|
||||
|
|
@ -79,7 +178,20 @@
|
|||
<tbody>
|
||||
[% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %]
|
||||
[% FOREACH register IN registers %]
|
||||
[% SET rbankable = ( register.outstanding_accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 ) %]
|
||||
[% SET rtotal = ( register.outstanding_accountlines.total() * -1 ) %]
|
||||
<tr>
|
||||
[% IF CAN_user_cash_management_cashup %]
|
||||
<td>
|
||||
[% IF register.cashup_in_progress %]
|
||||
<input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="Cashup in progress" />
|
||||
[% ELSIF rtotal == 0 %]
|
||||
<input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="No transactions" />
|
||||
[% ELSE %]
|
||||
<input type="checkbox" class="register_checkbox" value="[% register.id | html %]" />
|
||||
[% END %]
|
||||
</td>
|
||||
[% END %]
|
||||
<td><a href="/cgi-bin/koha/pos/register.pl?registerid=[% register.id | uri %]">[% register.name | html %]</a></td>
|
||||
<td>[% register.description | html %]</td>
|
||||
<td>
|
||||
|
|
@ -92,7 +204,6 @@
|
|||
</td>
|
||||
<td>[% register.starting_float | $Price %]</td>
|
||||
<td>
|
||||
[% SET rbankable = ( register.outstanding_accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 ) %]
|
||||
[% SET bankable = bankable + rbankable %]
|
||||
[% rbankable | $Price %]
|
||||
</td>
|
||||
|
|
@ -112,17 +223,33 @@
|
|||
</td>
|
||||
[% IF CAN_user_cash_management_cashup %]
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="cashup_individual btn btn-xs btn-default"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#confirmCashupModal"
|
||||
data-register="[% register.description | html %]"
|
||||
data-bankable="[% rbankable | $Price %]"
|
||||
data-float="[% register.starting_float | $Price %]"
|
||||
data-registerid="[% register.id | html %]"
|
||||
><i class="fa-solid fa-money-bill-1"></i> Record cashup</button
|
||||
>
|
||||
[% IF register.cashup_in_progress %]
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary pos_complete_cashup"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#confirmCashupModal"
|
||||
data-register="[% register.description | html %]"
|
||||
data-bankable="[% rbankable | $Price %]"
|
||||
data-expected="[% register.cashup_in_progress.amount | $Price %]"
|
||||
data-float="[% register.starting_float | $Price %]"
|
||||
data-registerid="[% register.id | html %]"
|
||||
><i class="fa-solid fa-check"></i> Complete cashup</button
|
||||
>
|
||||
[% ELSE %]
|
||||
<button
|
||||
type="button"
|
||||
class="cashup_individual btn btn-xs btn-default"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#triggerCashupModalRegister"
|
||||
data-register="[% register.description | html %]"
|
||||
data-bankable="[% rbankable | $Price %]"
|
||||
data-float="[% register.starting_float | $Price %]"
|
||||
data-registerid="[% register.id | html %]"
|
||||
[% IF rtotal == 0 %]disabled title="No transactions"[% END %]
|
||||
><i class="fa-solid fa-money-bill-1"></i> Record cashup</button
|
||||
>
|
||||
[% END %]
|
||||
</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
|
|
@ -130,13 +257,19 @@
|
|||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" align="right">Totals:</td>
|
||||
[% IF CAN_user_cash_management_cashup %]
|
||||
<td colspan="5" align="right">Totals:</td>
|
||||
[% ELSE %]
|
||||
<td colspan="4" align="right">Totals:</td>
|
||||
[% END %]
|
||||
<td>[% bankable | $Price %]</td>
|
||||
<td>[% ctotal | $Price %] ([% cctotal | $Price %])</td>
|
||||
<td>[% dtotal | $Price %] ([% cdtotal | $Price %])</td>
|
||||
[% IF CAN_user_cash_management_cashup %]
|
||||
<td>
|
||||
<button type="button" class="cashup_all btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupAllModal"><i class="fa-solid fa-money-bill-1"></i> Cashup all</button>
|
||||
<button type="button" id="cashup_selected_btn" class="btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupSelectedModal" disabled
|
||||
><i class="fa-solid fa-money-bill-1"></i> Cashup selected</button
|
||||
>
|
||||
</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
|
|
@ -147,34 +280,67 @@
|
|||
[% END %]
|
||||
[% END %]
|
||||
|
||||
<!-- Confirm cashup modal -->
|
||||
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
|
||||
<form id="cashup_individual_form" method="post" enctype="multipart/form-data">
|
||||
<!-- Confirm cashup selected modal -->
|
||||
<div class="modal" id="confirmCashupSelectedModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupSelectedLabel">
|
||||
<form method="post" class="validated">
|
||||
[% INCLUDE 'csrf-token.inc' %]
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title" id="confirmCashupLabel"
|
||||
>Confirm cashup of <em><span id="registerc"></span></em
|
||||
></h1>
|
||||
<h1 class="modal-title" id="confirmCashupSelectedLabel">Cashup selected registers</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body"> Please confirm that you have removed <span id="cashc"></span> from the cash register and left a float of <span id="floatc"></span>. </div>
|
||||
<!-- /.modal-body -->
|
||||
<div class="modal-body">
|
||||
<p
|
||||
><strong>Choose how to proceed with the cashup for <span id="selected_count">0</span> selected register(s):</strong></p
|
||||
>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-play"></i> Start cashup for selected</h5>
|
||||
<p class="card-text">Begin two-phase cashup for all selected registers. Cash can be removed for counting while registers continue operating.</p>
|
||||
<ul class="small">
|
||||
<li>Remove cash from each register for counting</li>
|
||||
<li>Registers continue operating during counting</li>
|
||||
<li>Complete each register individually later</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-lightning"></i> Quick cashup for selected</h5>
|
||||
<p class="card-text">Complete cashup immediately for all selected registers using expected amounts (no reconciliation needed).</p>
|
||||
<ul class="small">
|
||||
<li>Uses expected amounts for each register</li>
|
||||
<li>No individual reconciliation</li>
|
||||
<li>Completes all selected registers immediately</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Selected registers:</h6>
|
||||
<ul id="selected_registers_list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="registerid" id="cashup_registerid" value="" />
|
||||
<input type="hidden" name="op" value="cud-cashup" />
|
||||
<button type="submit" class="btn btn-primary" id="cashup_confirm">Confirm</button>
|
||||
<input type="hidden" name="registerid" id="selected_registers_field" value="" />
|
||||
<input type="hidden" name="op" id="selected_operation" value="" />
|
||||
<button type="button" class="btn btn-primary" id="start_selected_btn">Start cashup for selected</button>
|
||||
<button type="button" class="btn btn-success" id="quick_selected_btn">Quick cashup for selected</button>
|
||||
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
<!-- /.modal-footer -->
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- /#confirmCashupModal -->
|
||||
<!-- /#confirmCashupSelectedModal -->
|
||||
|
||||
<!-- Confirm cashupall modal -->
|
||||
<div class="modal" id="confirmCashupAllModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupAllLabel">
|
||||
|
|
@ -209,12 +375,15 @@
|
|||
<!-- /#confirmCashupAllModal -->
|
||||
|
||||
[% INCLUDE 'modals/cashup_summary.inc' %]
|
||||
[% INCLUDE 'modals/trigger_cashup.inc' modal_id='triggerCashupModalRegister' form_action='/cgi-bin/koha/pos/register.pl' redirect_to='registers' %]
|
||||
[% INCLUDE 'modals/confirm_cashup.inc' modal_id='confirmCashupModal' reconciliation_note_avs=reconciliation_note_avs reconciliation_note_required=reconciliation_note_required form_action='/cgi-bin/koha/pos/register.pl' redirect_to='registers' %]
|
||||
|
||||
[% MACRO jsinclude BLOCK %]
|
||||
[% INCLUDE 'datatables.inc' %]
|
||||
[% INCLUDE 'format_price.inc' %]
|
||||
[% INCLUDE 'js-date-format.inc' %]
|
||||
[% Asset.js("js/cashup_modal.js") | $raw %]
|
||||
[% Asset.js("js/modals/cashup_modals.js") | $raw %]
|
||||
[% Asset.js("js/modal_printer.js") | $raw %]
|
||||
<script nonce="[% Koha.CSPNonce | $raw %]">
|
||||
$(document).ready(function () {
|
||||
|
|
@ -227,20 +396,95 @@
|
|||
<script nonce="[% Koha.CSPNonce | $raw %]">
|
||||
$(document).ready(function () {
|
||||
var registers_table = $("#registers").kohaTable({
|
||||
columnDefs: [{ targets: [ -1, 0 ], orderable: false }],
|
||||
searching: false,
|
||||
paginationType: "full",
|
||||
});
|
||||
|
||||
$("#confirmCashupModal").on("shown.bs.modal", function (e) {
|
||||
var button = $(e.relatedTarget);
|
||||
var register = button.data("register");
|
||||
$("#registerc").text(register);
|
||||
var bankable = button.data("bankable");
|
||||
$("#cashc").text(bankable);
|
||||
var rfloat = button.data("float");
|
||||
$("#floatc").text(rfloat);
|
||||
var rid = button.data("registerid");
|
||||
$("#cashup_registerid").val(rid);
|
||||
// Initialize cashup modals
|
||||
initTriggerCashupModal('#triggerCashupModalRegister');
|
||||
|
||||
initConfirmCashupModal('#confirmCashupModal', {
|
||||
hasAuthorisedValues: [% reconciliation_note_avs ? 'true' : 'false' | html %],
|
||||
noteRequired: [% reconciliation_note_required ? 'true' : 'false' | html %]
|
||||
});
|
||||
|
||||
// Select all registers functionality
|
||||
$("#select_all_registers").on("change", function() {
|
||||
var isChecked = $(this).is(":checked");
|
||||
$(".register_checkbox:not(:disabled)").prop("checked", isChecked);
|
||||
updateCashupSelectedButton();
|
||||
});
|
||||
|
||||
// Individual checkbox change handler
|
||||
$(".register_checkbox").on("change", function() {
|
||||
updateCashupSelectedButton();
|
||||
|
||||
// Update select all checkbox state
|
||||
var totalCheckboxes = $(".register_checkbox:not(:disabled)").length;
|
||||
var checkedCheckboxes = $(".register_checkbox:not(:disabled):checked").length;
|
||||
|
||||
if (checkedCheckboxes === 0) {
|
||||
$("#select_all_registers").prop("indeterminate", false).prop("checked", false);
|
||||
} else if (checkedCheckboxes === totalCheckboxes) {
|
||||
$("#select_all_registers").prop("indeterminate", false).prop("checked", true);
|
||||
} else {
|
||||
$("#select_all_registers").prop("indeterminate", true);
|
||||
}
|
||||
});
|
||||
|
||||
// Update cashup selected button state
|
||||
function updateCashupSelectedButton() {
|
||||
var selectedCount = $(".register_checkbox:checked").length;
|
||||
var button = $("#cashup_selected_btn");
|
||||
|
||||
if (selectedCount > 0) {
|
||||
button.prop("disabled", false).removeClass("btn-default").addClass("btn-primary");
|
||||
} else {
|
||||
button.prop("disabled", true).removeClass("btn-primary").addClass("btn-default");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cashup selected modal
|
||||
$("#confirmCashupSelectedModal").on("shown.bs.modal", function(e) {
|
||||
var selectedCheckboxes = $(".register_checkbox:checked");
|
||||
var selectedCount = selectedCheckboxes.length;
|
||||
var selectedIds = [];
|
||||
var selectedNames = [];
|
||||
|
||||
selectedCheckboxes.each(function() {
|
||||
var registerRow = $(this).closest("tr");
|
||||
var registerId = $(this).val();
|
||||
var registerName = registerRow.find("td:nth-child(2) a").text(); // Second column (after checkbox)
|
||||
|
||||
selectedIds.push(registerId);
|
||||
selectedNames.push(registerName);
|
||||
});
|
||||
|
||||
$("#selected_count").text(selectedCount);
|
||||
$("#selected_registers_field").val(selectedIds.join(","));
|
||||
|
||||
// Populate register list
|
||||
var list = $("#selected_registers_list").empty();
|
||||
selectedNames.forEach(function(name) {
|
||||
$("<li>").text(name).appendTo(list);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle start cashup for selected
|
||||
$("#start_selected_btn").on("click", function(e) {
|
||||
e.preventDefault();
|
||||
var form = $(this).closest("form");
|
||||
form.find("#selected_operation").val("cud-cashup_start");
|
||||
form.submit();
|
||||
});
|
||||
|
||||
// Handle quick cashup for selected
|
||||
$("#quick_selected_btn").on("click", function(e) {
|
||||
e.preventDefault();
|
||||
var form = $(this).closest("form");
|
||||
form.find("#selected_operation").val("cud-cashup");
|
||||
form.submit();
|
||||
});
|
||||
|
||||
// Check for cashup hash in URL
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Cashup Modal JavaScript Module
|
||||
* Shared initialization functions for cashup modals across POS register pages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize trigger cashup modal behavior
|
||||
* @param {string} modalSelector - jQuery selector for the modal (e.g., '#triggerCashupModal')
|
||||
* @param {object} options - Configuration options
|
||||
* @param {number} options.registerFloat - Starting float amount (for register.tt)
|
||||
* @param {number} options.bankableAmount - Bankable amount (for register.tt)
|
||||
*/
|
||||
function initTriggerCashupModal(modalSelector, options) {
|
||||
options = options || {};
|
||||
|
||||
$(modalSelector).on("shown.bs.modal", function (e) {
|
||||
var button = $(e.relatedTarget);
|
||||
var modal = $(this);
|
||||
|
||||
// Get data from button (for registers.tt) or options (for register.tt)
|
||||
var register = button.data("register");
|
||||
var bankable = button.data("bankable");
|
||||
var rfloat = button.data("float");
|
||||
var rid = button.data("registerid");
|
||||
|
||||
// For register.tt, use options if provided
|
||||
if (options.bankableAmount !== undefined) {
|
||||
bankable = options.bankableAmount;
|
||||
}
|
||||
if (options.registerFloat !== undefined) {
|
||||
rfloat = options.registerFloat;
|
||||
}
|
||||
|
||||
// Populate register description if available
|
||||
if (register) {
|
||||
modal.find(".register-description").text(register);
|
||||
}
|
||||
|
||||
// Set register ID if available
|
||||
if (rid) {
|
||||
modal.find(".register-id-field").val(rid);
|
||||
}
|
||||
|
||||
// Guard against undefined/null bankable value
|
||||
if (bankable === undefined || bankable === null) {
|
||||
console.error("Bankable amount is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse bankable amount (remove currency formatting, keep minus sign)
|
||||
var bankableAmount = String(bankable).replace(/[^0-9.-]/g, "");
|
||||
var numericAmount = parseFloat(bankableAmount);
|
||||
var isNegative = numericAmount < 0;
|
||||
|
||||
// Format amounts for display
|
||||
var absAmountFormatted = Math.abs(numericAmount).format_price();
|
||||
var floatFormatted = rfloat;
|
||||
if (typeof rfloat === "number") {
|
||||
floatFormatted = rfloat.format_price();
|
||||
}
|
||||
|
||||
// Update Start cashup instructions
|
||||
var startInstructions;
|
||||
if (isNegative) {
|
||||
startInstructions =
|
||||
"<li>" +
|
||||
__("Count cash in the register") +
|
||||
"</li>" +
|
||||
"<li>" +
|
||||
__("The register can continue operating during counting") +
|
||||
"</li>" +
|
||||
"<li>" +
|
||||
__("Complete the cashup by adding cash to restore the float") +
|
||||
"</li>";
|
||||
} else {
|
||||
startInstructions =
|
||||
"<li>" +
|
||||
__("Remove cash from the register for counting") +
|
||||
"</li>" +
|
||||
"<li>" +
|
||||
__("The register can continue operating during counting") +
|
||||
"</li>" +
|
||||
"<li>" +
|
||||
__("Complete the cashup once counted") +
|
||||
"</li>";
|
||||
}
|
||||
modal.find(".start-cashup-instructions").html(startInstructions);
|
||||
|
||||
// Update Quick cashup instructions
|
||||
var quickInstructions;
|
||||
if (isNegative) {
|
||||
quickInstructions =
|
||||
"<li>" +
|
||||
__("Top up the register with %s to restore the float").format(
|
||||
absAmountFormatted
|
||||
) +
|
||||
"</li>";
|
||||
} else {
|
||||
quickInstructions =
|
||||
"<li>" +
|
||||
__(
|
||||
"Confirm you have removed %s cash from the register to bank immediately"
|
||||
).format(absAmountFormatted) +
|
||||
"</li>";
|
||||
}
|
||||
modal.find(".quick-cashup-instructions").html(quickInstructions);
|
||||
|
||||
// Update float reminder
|
||||
var floatReminder;
|
||||
if (isNegative) {
|
||||
floatReminder = __(
|
||||
"This will bring the register back to the expected float of <strong>%s</strong>"
|
||||
).format(floatFormatted);
|
||||
} else {
|
||||
floatReminder = __(
|
||||
"Remember to leave the float amount of <strong>%s</strong> in the register."
|
||||
).format(floatFormatted);
|
||||
}
|
||||
modal.find(".float-reminder-text").html(floatReminder);
|
||||
|
||||
// Store bankable amount for quick cashup (with sign)
|
||||
modal.data("bankable-amount", bankableAmount);
|
||||
});
|
||||
|
||||
// Handle Quick cashup button click
|
||||
$(modalSelector + " .quick-cashup-btn").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
var form = $(this).closest("form");
|
||||
var modal = $(this).closest(".modal");
|
||||
var bankableAmount = modal.data("bankable-amount");
|
||||
|
||||
// Change operation to cud-cashup (quick cashup)
|
||||
form.find('input[name="op"]').val("cud-cashup");
|
||||
|
||||
// Set the amount to the expected bankable amount
|
||||
form.find('input[name="amount"]').val(bankableAmount);
|
||||
|
||||
// Submit the form
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize confirm cashup modal behavior with reconciliation calculation
|
||||
* @param {string} modalSelector - jQuery selector for the modal (e.g., '#confirmCashupModal')
|
||||
* @param {object} options - Configuration options
|
||||
* @param {boolean} options.noteRequired - Whether reconciliation note is required when there's a discrepancy
|
||||
* @param {boolean} options.hasAuthorisedValues - Whether authorized values are configured for notes
|
||||
* @param {boolean} options.isInProgress - Whether this is completing an in-progress cashup (for register.tt)
|
||||
*/
|
||||
function initConfirmCashupModal(modalSelector, options) {
|
||||
options = options || {};
|
||||
var noteRequired = options.noteRequired || false;
|
||||
var hasAuthorisedValues = options.hasAuthorisedValues || false;
|
||||
var isInProgress = options.isInProgress || false;
|
||||
|
||||
// Real-time reconciliation calculation
|
||||
$(modalSelector + " .cashup-amount-input").on("input", function () {
|
||||
var modal = $(this).closest(".modal");
|
||||
var actualAmount = parseFloat($(this).val()) || 0;
|
||||
var expectedText = modal.find(".expected-amount").text();
|
||||
var expectedAmount = parseFloat(expectedText.unformat_price()) || 0;
|
||||
var difference = actualAmount - expectedAmount;
|
||||
|
||||
if ($(this).val() && !isNaN(actualAmount)) {
|
||||
var reconciliationText = "";
|
||||
var reconciliationClass = "";
|
||||
var hasDiscrepancy = false;
|
||||
|
||||
if (difference > 0) {
|
||||
reconciliationText = __("Surplus: %s").format(
|
||||
difference.format_price()
|
||||
);
|
||||
reconciliationClass = "success";
|
||||
hasDiscrepancy = true;
|
||||
} else if (difference < 0) {
|
||||
reconciliationText = __("Deficit: %s").format(
|
||||
Math.abs(difference).format_price()
|
||||
);
|
||||
reconciliationClass = "warning";
|
||||
hasDiscrepancy = true;
|
||||
} else {
|
||||
reconciliationText = __("Balanced - no surplus or deficit");
|
||||
reconciliationClass = "success";
|
||||
hasDiscrepancy = false;
|
||||
}
|
||||
|
||||
modal
|
||||
.find(".reconciliation-text")
|
||||
.text(reconciliationText)
|
||||
.removeClass("success warning")
|
||||
.addClass(reconciliationClass);
|
||||
modal.find(".reconciliation-display").show();
|
||||
|
||||
// Show/hide note field based on whether there's a discrepancy
|
||||
if (hasDiscrepancy) {
|
||||
modal.find(".reconciliation-note-field").show();
|
||||
|
||||
// Update required attribute and label based on system preference
|
||||
if (noteRequired) {
|
||||
modal
|
||||
.find(".reconciliation-note-input")
|
||||
.attr("required", "required");
|
||||
modal
|
||||
.find(".reconciliation-note-label")
|
||||
.addClass("required");
|
||||
} else {
|
||||
modal
|
||||
.find(".reconciliation-note-input")
|
||||
.removeAttr("required");
|
||||
modal
|
||||
.find(".reconciliation-note-label")
|
||||
.removeClass("required");
|
||||
}
|
||||
} else {
|
||||
modal.find(".reconciliation-note-field").hide();
|
||||
modal.find(".reconciliation-note-input").val(""); // Clear note when balanced
|
||||
modal.find(".reconciliation-note-input").removeAttr("required");
|
||||
modal
|
||||
.find(".reconciliation-note-label")
|
||||
.removeClass("required");
|
||||
}
|
||||
} else {
|
||||
modal.find(".reconciliation-display").hide();
|
||||
modal.find(".reconciliation-note-field").hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset/populate modal when opened
|
||||
$(modalSelector).on("shown.bs.modal", function (e) {
|
||||
var button = $(e.relatedTarget);
|
||||
var modal = $(this);
|
||||
|
||||
// For registers.tt: populate from button data
|
||||
if (button.length && button.data("register")) {
|
||||
var register = button.data("register");
|
||||
modal.find(".register-name").text(register);
|
||||
|
||||
var expected = button.data("expected");
|
||||
modal.find(".expected-amount").text(expected);
|
||||
|
||||
var rid = button.data("registerid");
|
||||
modal.find(".register-id-field").val(rid);
|
||||
|
||||
// Parse expected amount to check if negative
|
||||
// Convert to string first in case jQuery's .data() parsed it as a number
|
||||
var expectedAmount = String(expected || "").replace(
|
||||
/[^0-9.-]/g,
|
||||
""
|
||||
);
|
||||
var isNegative = parseFloat(expectedAmount) < 0;
|
||||
|
||||
// Update labels based on sign
|
||||
if (isNegative) {
|
||||
modal
|
||||
.find(".expected-amount-label")
|
||||
.text(__("Expected amount to add:"));
|
||||
modal
|
||||
.find(".actual-amount-label")
|
||||
.text(__("Actual amount added to register:"));
|
||||
} else {
|
||||
modal
|
||||
.find(".expected-amount-label")
|
||||
.text(__("Expected cashup amount:"));
|
||||
modal
|
||||
.find(".actual-amount-label")
|
||||
.text(__("Actual cashup amount counted:"));
|
||||
}
|
||||
}
|
||||
|
||||
// Reset fields
|
||||
modal.find(".cashup-amount-input").val("").focus();
|
||||
modal.find(".reconciliation-display").hide();
|
||||
modal.find(".reconciliation-note-field").hide();
|
||||
modal.find(".reconciliation-note-input").val("");
|
||||
});
|
||||
}
|
||||
110
pos/register.pl
110
pos/register.pl
|
|
@ -63,11 +63,27 @@ if ( !$registers->count ) {
|
|||
registers => $registers,
|
||||
);
|
||||
|
||||
my $cash_register = Koha::Cash::Registers->find( { id => $registerid } );
|
||||
my $accountlines = $cash_register->outstanding_accountlines();
|
||||
my $cash_register = Koha::Cash::Registers->find( { id => $registerid } );
|
||||
my $accountlines = $cash_register->outstanding_accountlines();
|
||||
my $cashup_in_progress = $cash_register->cashup_in_progress();
|
||||
|
||||
# Get authorized values for reconciliation notes if configured
|
||||
my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
|
||||
my $reconciliation_note_avs;
|
||||
if ($note_av_category) {
|
||||
require Koha::AuthorisedValues;
|
||||
$reconciliation_note_avs = Koha::AuthorisedValues->search(
|
||||
{ category => $note_av_category },
|
||||
{ order_by => { '-asc' => 'lib' } }
|
||||
);
|
||||
}
|
||||
|
||||
$template->param(
|
||||
register => $cash_register,
|
||||
accountlines => $accountlines
|
||||
register => $cash_register,
|
||||
accountlines => $accountlines,
|
||||
cashup_in_progress => $cashup_in_progress,
|
||||
reconciliation_note_avs => $reconciliation_note_avs,
|
||||
reconciliation_note_required => C4::Context->preference('CashupReconciliationNoteRequired'),
|
||||
);
|
||||
|
||||
my $transactions_range_from = $input->param('trange_f');
|
||||
|
|
@ -102,28 +118,90 @@ if ( !$registers->count ) {
|
|||
$template->param( trange_t => $end, );
|
||||
|
||||
my $op = $input->param('op') // '';
|
||||
if ( $op eq 'cud-cashup' ) {
|
||||
if ( $op eq 'cud-cashup_start' ) {
|
||||
if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
|
||||
eval {
|
||||
$cash_register->start_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
}
|
||||
);
|
||||
};
|
||||
if ($@) {
|
||||
if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
|
||||
$template->param( error_cashup_in_progress => 1 );
|
||||
} elsif ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
|
||||
$template->param( error_cashup_no_transactions => 1 );
|
||||
} else {
|
||||
$template->param( error_cashup_start => 1 );
|
||||
}
|
||||
} else {
|
||||
|
||||
# Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
|
||||
my $redirect_to = $input->param('redirect_to') || 'register';
|
||||
|
||||
if ( $redirect_to eq 'registers' ) {
|
||||
print $input->redirect("/cgi-bin/koha/pos/registers.pl?cashup_start_success=1");
|
||||
} else {
|
||||
print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
|
||||
}
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
$template->param( error_cashup_permission => 1 );
|
||||
}
|
||||
} elsif ( $op eq 'cud-cashup' ) {
|
||||
if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
|
||||
my $amount = $input->param('amount');
|
||||
|
||||
# add_cashup() sanitizes (trims, limits length, empty-to-undef) the note itself
|
||||
my $reconciliation_note = $input->param('reconciliation_note');
|
||||
|
||||
if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) {
|
||||
if ( defined $amount && $amount =~ /^-?\d+(?:\.\d{1,2})?$/ ) {
|
||||
|
||||
my $cashup = $cash_register->add_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $amount,
|
||||
reconciliation_note => $reconciliation_note
|
||||
my $cashup;
|
||||
eval {
|
||||
$cashup = $cash_register->add_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $amount,
|
||||
reconciliation_note => $reconciliation_note
|
||||
}
|
||||
);
|
||||
};
|
||||
if ($@) {
|
||||
if ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
|
||||
$template->param( error_no_cashup_start => 1 );
|
||||
} elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
|
||||
$template->param( error_cashup_already_completed => 1 );
|
||||
} elsif ( $@->isa('Koha::Exceptions::MissingParameter') ) {
|
||||
|
||||
# Check if this is a reconciliation note error specifically
|
||||
if ( $@->error =~ /Reconciliation note is required/ ) {
|
||||
$template->param( error_reconciliation_note_required => 1 );
|
||||
} else {
|
||||
$template->param( error_cashup_missing_param => 1, error_message => $@ );
|
||||
}
|
||||
} elsif ( $@->isa('Koha::Exceptions::Account::AmountNotPositive') ) {
|
||||
$template->param( error_cashup_amount_invalid => 1 );
|
||||
} else {
|
||||
|
||||
# Log the actual exception for debugging
|
||||
$template->param( error_cashup_complete => 1, error_details => "$@" );
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
||||
# Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
|
||||
print $input->redirect(
|
||||
"/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
|
||||
exit;
|
||||
# Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
|
||||
my $redirect_to = $input->param('redirect_to') || 'register';
|
||||
|
||||
if ( $redirect_to eq 'registers' ) {
|
||||
print $input->redirect("/cgi-bin/koha/pos/registers.pl?cashup_complete_success=1");
|
||||
} else {
|
||||
print $input->redirect(
|
||||
"/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
|
||||
}
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
$template->param( error_cashup_amount => 1 );
|
||||
}
|
||||
|
|
|
|||
208
pos/registers.pl
208
pos/registers.pl
|
|
@ -19,8 +19,9 @@
|
|||
|
||||
use Modern::Perl;
|
||||
use CGI;
|
||||
use C4::Auth qw( get_template_and_user );
|
||||
use C4::Output qw( output_html_with_http_headers );
|
||||
use URI::Escape qw( uri_escape uri_unescape );
|
||||
use C4::Auth qw( get_template_and_user );
|
||||
use C4::Output qw( output_html_with_http_headers );
|
||||
use C4::Context;
|
||||
|
||||
use Koha::Cash::Registers;
|
||||
|
|
@ -42,6 +43,17 @@ my $logged_in_user = Koha::Patrons->find($loggedinuser) or die "Not logged in";
|
|||
my $library = Koha::Libraries->find( C4::Context->userenv->{'branch'} );
|
||||
$template->param( library => $library );
|
||||
|
||||
# Get authorized values for reconciliation notes if configured
|
||||
my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
|
||||
my $reconciliation_note_avs;
|
||||
if ($note_av_category) {
|
||||
require Koha::AuthorisedValues;
|
||||
$reconciliation_note_avs = Koha::AuthorisedValues->search(
|
||||
{ category => $note_av_category },
|
||||
{ order_by => { '-asc' => 'lib' } }
|
||||
);
|
||||
}
|
||||
|
||||
my $registers = Koha::Cash::Registers->search(
|
||||
{ branch => $library->id, archived => 0 },
|
||||
{ order_by => { '-asc' => 'name' } }
|
||||
|
|
@ -50,37 +62,189 @@ my $registers = Koha::Cash::Registers->search(
|
|||
if ( !$registers->count ) {
|
||||
$template->param( error_registers => 1 );
|
||||
} else {
|
||||
$template->param( registers => $registers );
|
||||
$template->param(
|
||||
registers => $registers,
|
||||
reconciliation_note_avs => $reconciliation_note_avs,
|
||||
reconciliation_note_required => C4::Context->preference('CashupReconciliationNoteRequired'),
|
||||
);
|
||||
}
|
||||
|
||||
# Handle success/error messages from redirects
|
||||
my $cashup_start_success = $input->param('cashup_start_success');
|
||||
my $cashup_start_errors = $input->param('cashup_start_errors');
|
||||
my $cashup_start_error_detail = $input->param('cashup_start_error_detail');
|
||||
my $cashup_complete_success = $input->param('cashup_complete_success');
|
||||
my $cashup_complete_errors = $input->param('cashup_complete_errors');
|
||||
my $cashup_complete_error_detail = $input->param('cashup_complete_error_detail');
|
||||
|
||||
if ($cashup_start_success) {
|
||||
$template->param( cashup_start_success => $cashup_start_success );
|
||||
}
|
||||
if ($cashup_start_errors) {
|
||||
$template->param( cashup_start_errors => $cashup_start_errors );
|
||||
}
|
||||
if ($cashup_start_error_detail) {
|
||||
$template->param(
|
||||
cashup_start_error_details => [ map { uri_unescape($_) } split( /,/, $cashup_start_error_detail ) ] );
|
||||
}
|
||||
if ($cashup_complete_success) {
|
||||
$template->param( cashup_complete_success => $cashup_complete_success );
|
||||
}
|
||||
if ($cashup_complete_errors) {
|
||||
$template->param( cashup_complete_errors => $cashup_complete_errors );
|
||||
}
|
||||
if ($cashup_complete_error_detail) {
|
||||
$template->param(
|
||||
cashup_complete_error_details => [ map { uri_unescape($_) } split( /,/, $cashup_complete_error_detail ) ] );
|
||||
}
|
||||
|
||||
my $op = $input->param('op') // '';
|
||||
if ( $op eq 'cud-cashup' ) {
|
||||
if ( $op eq 'cud-cashup_start' ) {
|
||||
if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
|
||||
my $registerid = $input->param('registerid');
|
||||
my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
|
||||
if ($registerid) {
|
||||
my $register = Koha::Cash::Registers->find( { id => $registerid } );
|
||||
my $cashup = $register->add_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $register->outstanding_accountlines->total
|
||||
}
|
||||
);
|
||||
$redirect_url .= "#cashup-" . $cashup->id;
|
||||
} else {
|
||||
for my $register ( $registers->as_list ) {
|
||||
$register->add_cashup(
|
||||
my $registerid_param = $input->param('registerid');
|
||||
my @register_ids = split( ',', $registerid_param );
|
||||
my @errors = ();
|
||||
my $success_count = 0;
|
||||
|
||||
foreach my $register_id (@register_ids) {
|
||||
$register_id =~ s/^\s+|\s+$//g; # Trim whitespace
|
||||
next unless $register_id;
|
||||
|
||||
my $register = Koha::Cash::Registers->find( { id => $register_id } );
|
||||
next unless $register;
|
||||
|
||||
eval {
|
||||
$register->start_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $register->outstanding_accountlines->total
|
||||
}
|
||||
);
|
||||
$success_count++;
|
||||
};
|
||||
if ($@) {
|
||||
if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
|
||||
push @errors, "Register " . $register->name . ": Cashup already in progress";
|
||||
} elsif ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
|
||||
push @errors, "Register " . $register->name . ": No cash transactions to cashup";
|
||||
} else {
|
||||
push @errors, "Register " . $register->name . ": Failed to start cashup";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
|
||||
print $input->redirect($redirect_url);
|
||||
exit;
|
||||
if ( @errors && $success_count == 0 ) {
|
||||
|
||||
# All failed - stay on page to show errors
|
||||
$template->param(
|
||||
error_cashup_start => 1,
|
||||
cashup_errors => \@errors
|
||||
);
|
||||
} else {
|
||||
|
||||
# Some or all succeeded - redirect with coded parameters
|
||||
my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
|
||||
my @params;
|
||||
|
||||
if ( $success_count > 0 ) {
|
||||
push @params, "cashup_start_success=" . $success_count;
|
||||
}
|
||||
if (@errors) {
|
||||
push @params, "cashup_start_errors=" . scalar(@errors);
|
||||
push @params, "cashup_start_error_detail=" . join( ',', map { uri_escape($_) } @errors );
|
||||
}
|
||||
|
||||
if (@params) {
|
||||
$redirect_url .= "?" . join( "&", @params );
|
||||
}
|
||||
|
||||
print $input->redirect($redirect_url);
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
$template->param( error_cashup_permission => 1 );
|
||||
}
|
||||
} elsif ( $op eq 'cud-cashup' ) {
|
||||
if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
|
||||
my $registerid_param = $input->param('registerid');
|
||||
my @register_ids = split( ',', $registerid_param );
|
||||
my @errors = ();
|
||||
my $success_count = 0;
|
||||
|
||||
foreach my $register_id (@register_ids) {
|
||||
$register_id =~ s/^\s+|\s+$//g; # Trim whitespace
|
||||
next unless $register_id;
|
||||
|
||||
my $register = Koha::Cash::Registers->find( { id => $register_id } );
|
||||
next unless $register;
|
||||
|
||||
eval {
|
||||
# Get the amount from the request parameter
|
||||
# For quick cashup, this will be the expected amount set by JavaScript
|
||||
# For two-stage cashup completion, this will be the user-entered actual amount
|
||||
my $amount = $input->param('amount');
|
||||
|
||||
# If no amount provided, calculate expected amount (backwards compatibility)
|
||||
unless ( defined $amount && $amount ne '' ) {
|
||||
$amount =
|
||||
$register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1;
|
||||
}
|
||||
|
||||
# Get optional reconciliation note
|
||||
my $reconciliation_note = $input->param('reconciliation_note');
|
||||
|
||||
# Complete the cashup
|
||||
my %cashup_params = (
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $amount,
|
||||
);
|
||||
|
||||
# Add reconciliation note if provided
|
||||
if ( defined $reconciliation_note && $reconciliation_note ne '' ) {
|
||||
$cashup_params{reconciliation_note} = $reconciliation_note;
|
||||
}
|
||||
|
||||
$register->add_cashup( \%cashup_params );
|
||||
$success_count++;
|
||||
};
|
||||
if ($@) {
|
||||
if ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
|
||||
push @errors, "Register " . $register->name . ": No cashup session to complete";
|
||||
} elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
|
||||
push @errors, "Register " . $register->name . ": Cashup already completed";
|
||||
} else {
|
||||
push @errors, "Register " . $register->name . ": Failed to complete cashup";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( @errors && $success_count == 0 ) {
|
||||
|
||||
# All failed - stay on page to show errors
|
||||
$template->param(
|
||||
error_cashup_complete => 1,
|
||||
cashup_errors => \@errors
|
||||
);
|
||||
} else {
|
||||
|
||||
# Some or all succeeded - redirect with coded parameters
|
||||
my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
|
||||
my @params;
|
||||
|
||||
if ( $success_count > 0 ) {
|
||||
push @params, "cashup_complete_success=" . $success_count;
|
||||
}
|
||||
if (@errors) {
|
||||
push @params, "cashup_complete_errors=" . scalar(@errors);
|
||||
push @params, "cashup_complete_error_detail=" . join( ',', map { uri_escape($_) } @errors );
|
||||
}
|
||||
|
||||
if (@params) {
|
||||
$redirect_url .= "?" . join( "&", @params );
|
||||
}
|
||||
|
||||
print $input->redirect($redirect_url);
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
$template->param( error_cashup_permission => 1 );
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,9 +19,10 @@
|
|||
|
||||
use Modern::Perl;
|
||||
use Test::NoWarnings;
|
||||
use Test::More tests => 4;
|
||||
use Test::More tests => 6;
|
||||
|
||||
use Koha::Database;
|
||||
use Koha::DateUtils qw( dt_from_string );
|
||||
|
||||
use t::lib::TestBuilder;
|
||||
|
||||
|
|
@ -359,4 +360,316 @@ subtest 'summary' => sub {
|
|||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
subtest 'accountlines' => sub {
|
||||
plan tests => 3;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
|
||||
my $manager = $builder->build_object( { class => 'Koha::Patrons' } );
|
||||
|
||||
# Test 1: Basic functionality
|
||||
subtest 'basic_accountlines_functionality' => sub {
|
||||
plan tests => 2;
|
||||
|
||||
my $account = $patron->account;
|
||||
my $fine = $account->add_debit(
|
||||
{
|
||||
amount => '10.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
$fine->date( \'NOW() - INTERVAL 30 MINUTE' )->store;
|
||||
|
||||
my $payment = $account->pay(
|
||||
{
|
||||
cash_register => $register->id,
|
||||
amount => '10.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$fine]
|
||||
}
|
||||
);
|
||||
my $payment_line = Koha::Account::Lines->find( $payment->{payment_id} );
|
||||
$payment_line->date( \'NOW() - INTERVAL 25 MINUTE' )->store;
|
||||
|
||||
# Cashup
|
||||
my $cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
|
||||
|
||||
# Check accountlines method exists and returns correct type
|
||||
my $accountlines = $cashup->accountlines;
|
||||
is( ref($accountlines), 'Koha::Account::Lines', 'accountlines returns Koha::Account::Lines object' );
|
||||
ok( $accountlines->count >= 0, 'accountlines returns a valid count' );
|
||||
};
|
||||
|
||||
# Test 2: Two-phase workflow basics
|
||||
subtest 'two_phase_basics' => sub {
|
||||
plan tests => 3;
|
||||
|
||||
my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $account2 = $patron->account;
|
||||
|
||||
# Add initial cash transaction before starting cashup
|
||||
my $initial_fine = $account2->add_debit(
|
||||
{
|
||||
amount => '3.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $initial_payment = $account2->pay(
|
||||
{
|
||||
cash_register => $register2->id,
|
||||
amount => '3.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$initial_fine]
|
||||
}
|
||||
);
|
||||
|
||||
# Start cashup first
|
||||
my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } );
|
||||
|
||||
# Add transaction after start
|
||||
my $fine = $account2->add_debit(
|
||||
{
|
||||
amount => '5.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $payment = $account2->pay(
|
||||
{
|
||||
cash_register => $register2->id,
|
||||
amount => '5.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$fine]
|
||||
}
|
||||
);
|
||||
|
||||
# Complete cashup
|
||||
my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
|
||||
|
||||
# Check accountlines
|
||||
my $accountlines = $cashup_complete->accountlines;
|
||||
is( ref($accountlines), 'Koha::Account::Lines', 'Two-phase accountlines returns correct type' );
|
||||
ok( $accountlines->count >= 0, 'Two-phase accountlines returns valid count' );
|
||||
|
||||
# Check filtering capability
|
||||
my $filtered = $accountlines->search( { payment_type => 'CASH' } );
|
||||
ok( defined $filtered, 'Accountlines can be filtered' );
|
||||
};
|
||||
|
||||
# Test 3: Reconciliation inclusion
|
||||
subtest 'reconciliation_inclusion' => sub {
|
||||
plan tests => 2;
|
||||
|
||||
my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $account3 = $patron->account;
|
||||
|
||||
# Create transaction
|
||||
my $fine = $account3->add_debit(
|
||||
{
|
||||
amount => '20.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $payment = $account3->pay(
|
||||
{
|
||||
cash_register => $register3->id,
|
||||
amount => '20.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$fine]
|
||||
}
|
||||
);
|
||||
|
||||
# Cashup with surplus to create reconciliation line
|
||||
my $cashup = $register3->add_cashup(
|
||||
{
|
||||
manager_id => $manager->id,
|
||||
amount => '25.00' # Creates surplus
|
||||
}
|
||||
);
|
||||
|
||||
my $accountlines = $cashup->accountlines;
|
||||
ok( $accountlines->count >= 1, 'Accountlines includes transactions when surplus created' );
|
||||
|
||||
# Verify surplus line exists
|
||||
my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } );
|
||||
is( $surplus_lines->count, 1, 'Surplus reconciliation line is included' );
|
||||
};
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
subtest 'summary_session_boundaries' => sub {
|
||||
plan tests => 4;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
|
||||
my $manager = $builder->build_object( { class => 'Koha::Patrons' } );
|
||||
|
||||
# Test 1: Basic summary functionality
|
||||
subtest 'basic_summary_functionality' => sub {
|
||||
plan tests => 3;
|
||||
|
||||
my $account = $patron->account;
|
||||
|
||||
# Create a simple transaction and cashup
|
||||
my $fine = $account->add_debit(
|
||||
{
|
||||
amount => '10.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $payment = $account->pay(
|
||||
{
|
||||
cash_register => $register->id,
|
||||
amount => '10.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$fine]
|
||||
}
|
||||
);
|
||||
|
||||
my $cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
|
||||
my $summary = $cashup->summary;
|
||||
|
||||
# Basic summary structure validation
|
||||
ok( defined $summary->{from_date} || !defined $summary->{from_date}, 'Summary has from_date field' );
|
||||
ok( defined $summary->{to_date}, 'Summary has to_date field' );
|
||||
ok( defined $summary->{total}, 'Summary has total field' );
|
||||
};
|
||||
|
||||
# Test 2: Two-phase workflow basic functionality
|
||||
subtest 'two_phase_basic_functionality' => sub {
|
||||
plan tests => 4;
|
||||
|
||||
my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $account = $patron->account;
|
||||
|
||||
# Add initial cash transaction before starting cashup
|
||||
my $initial_fine = $account->add_debit(
|
||||
{
|
||||
amount => '5.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $initial_payment = $account->pay(
|
||||
{
|
||||
cash_register => $register2->id,
|
||||
amount => '5.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$initial_fine]
|
||||
}
|
||||
);
|
||||
|
||||
# Start two-phase cashup
|
||||
my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } );
|
||||
ok( defined $cashup_start, 'Two-phase cashup can be started' );
|
||||
|
||||
# Create transaction during session
|
||||
my $fine = $account->add_debit(
|
||||
{
|
||||
amount => '15.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $payment = $account->pay(
|
||||
{
|
||||
cash_register => $register2->id,
|
||||
amount => '15.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$fine]
|
||||
}
|
||||
);
|
||||
|
||||
# Complete two-phase cashup
|
||||
my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '15.00' } );
|
||||
ok( defined $cashup_complete, 'Two-phase cashup can be completed' );
|
||||
|
||||
my $summary = $cashup_complete->summary;
|
||||
ok( defined $summary, 'Two-phase completed cashup has summary' );
|
||||
ok( defined $summary->{total}, 'Two-phase summary has total' );
|
||||
};
|
||||
|
||||
# Test 3: Reconciliation functionality
|
||||
subtest 'reconciliation_functionality' => sub {
|
||||
plan tests => 2;
|
||||
|
||||
my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $account = $patron->account;
|
||||
|
||||
# Create transaction with surplus
|
||||
my $fine = $account->add_debit(
|
||||
{
|
||||
amount => '20.00',
|
||||
type => 'OVERDUE',
|
||||
interface => 'cron'
|
||||
}
|
||||
);
|
||||
|
||||
my $payment = $account->pay(
|
||||
{
|
||||
cash_register => $register3->id,
|
||||
amount => '20.00',
|
||||
credit_type => 'PAYMENT',
|
||||
payment_type => 'CASH',
|
||||
lines => [$fine]
|
||||
}
|
||||
);
|
||||
|
||||
# Cashup with surplus
|
||||
my $cashup = $register3->add_cashup(
|
||||
{
|
||||
manager_id => $manager->id,
|
||||
amount => '25.00' # Creates 5.00 surplus
|
||||
}
|
||||
);
|
||||
|
||||
my $summary = $cashup->summary;
|
||||
my $accountlines = $cashup->accountlines;
|
||||
|
||||
ok( defined $summary, 'Cashup with reconciliation has summary' );
|
||||
|
||||
# Check surplus reconciliation exists
|
||||
my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } );
|
||||
is( $surplus_lines->count, 1, 'Surplus reconciliation line is created and included' );
|
||||
};
|
||||
|
||||
# Test 4: Edge cases
|
||||
subtest 'edge_cases' => sub {
|
||||
plan tests => 2;
|
||||
|
||||
my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
|
||||
# Empty cashup
|
||||
my $empty_cashup = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
|
||||
my $summary = $empty_cashup->summary;
|
||||
|
||||
ok( defined $summary, 'Empty cashup has summary' );
|
||||
ok( defined $summary->{total}, 'Empty cashup summary has total' );
|
||||
};
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
1;
|
||||
|
|
|
|||
Loading…
Reference in New Issue