The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
use utf8;

package Interchange6::Schema::Result::Zone;

=head1 NAME

Interchange6::Schema::Result::Zone

=cut

use strict;
use warnings;
use DateTime;
use Scalar::Util qw(blessed);

use base 'DBIx::Class::Core';

__PACKAGE__->load_components(qw(InflateColumn::DateTime TimeStamp));

=head1 TABLE: C<zones>

=cut

__PACKAGE__->table("zones");

=head1 DESCRIPTION

In the context of zones the term 'state' refers to state, province or other principal subdivision of a country as defined in L<ISO 3116-2|http://en.wikipedia.org/wiki/ISO_3166-2>. Countries to be added to a zone must already exist in L<Interchange6::Schema::Result::Country> and states in L<Interchange6::Schema::Result::State>.

Zones can contain any of the following:

=over 4

=item * No countries and no states

An empty zone must be created before countries/states are added but otherwise is probably not useful.

=item * Multiple countries

For example to create a trading group like the European Union.

=item * A single country


=item * A single country with a single state

For example Quebec in Canada which has GST + QST

=item * A single country with multiple states

For example a group containing all Canadian provinces that charge only GST.

=back

The following combinations are NOT allowed:

=over 4

=item * Multiple countries with one or more states

=item * One or more states with no country

=back

Countries and states should be added to and removed from the zone using these methods which are described further below:

=over 4

=item * add_countries

=item * remove_countries

=item * add_states

=item * remove_states

=back

B<NOTE:> avoid using other methods from L<DBIx::Class::Relationship::Base> since you may inadvertently end up with an invalid zone.

=head1 ACCESSORS

=head2 zones_id

  data_type: 'integer'
  is_auto_increment: 1
  is_nullable: 0
  sequence: 'zones_id_seq'

=head2 zone

For example for storing the UPS/USPS zone code or a simple name for the zone.

  data_type: 'varchar'
  is_nullable: 0
  size: 255

=head2 created

  data_type: 'datetime'
  set_on_create: 1
  is_nullable: 0

=head2 last_modified

  data_type: 'datetime'
  set_on_create: 1
  set_on_update: 1
  is_nullable: 0

=cut

__PACKAGE__->add_columns(
    "zones_id",
    {
        data_type         => "integer",
        is_auto_increment => 1,
        is_nullable       => 0,
        sequence          => "zones_id_seq"
    },
    "zone",
    { data_type => "varchar", is_nullable => 0, size  => 255 },
    "created",
    { data_type => "datetime", set_on_create => 1, is_nullable => 0 },
    "last_modified",
    {
        data_type     => "datetime",
        set_on_create => 1,
        set_on_update => 1,
        is_nullable   => 0
    },
);

=head1 PRIMARY KEY

=over 4

=item * L</zones_id>

=back

=cut

__PACKAGE__->set_primary_key("zones_id");

=head1 UNIQUE CONSTRAINTS

=head2 zones_zone

On ( zone )

=cut

__PACKAGE__->add_unique_constraint(
    zones_zone => [ 'zone' ],
);

=head1 RELATIONS

=head2 zone_countries

Type: has_many

Related object: L<Interchange6::Schema::Result::ZoneCountry>

=cut

__PACKAGE__->has_many(
    "zone_countries", "Interchange6::Schema::Result::ZoneCountry",
    "zones_id", { cascade_copy => 0, cascade_delete => 0 },
);

=head2 countries

Type: many_to_many

Accessor to related country results ordered by name.

=cut

__PACKAGE__->many_to_many( "countries", "zone_countries", "country",
    { order_by => 'country.name' } );

=head2 zone_states

Type: has_many

Related object: L<Interchange6::Schema::Result::ZoneState>

=cut

__PACKAGE__->has_many(
    "zone_states", "Interchange6::Schema::Result::ZoneState",
    "zones_id", { cascade_copy => 0, cascade_delete => 0 },
);

=head2 states

Type: many_to_many

Accessor to related state results ordered by name.

=cut

__PACKAGE__->many_to_many( "states", "zone_states", "state",
    { order_by => 'state.name' } );


=head2 shipment_destinations

C<has_many> relationship with
L<Interchange6::Schema::Result::ShipmentDestination>

=cut

__PACKAGE__->has_many(
                      "shipment_destinations",
                      "Interchange6::Schema::Result::ShipmentDestination",
                      "zones_id");

=head2 shipment_methods

C<many_to_many> relationship to shipment_method. Currently it ignores
the C<active> field in shipment_destinations.

=cut

__PACKAGE__->many_to_many("shipment_methods",
                          "shipment_destinations",
                          "shipment_method");

=head1 METHODS

=head2 new

Overloaded method. We allow a form of multi-create here so you can do something like:

    $schema->resultset('Zone')->create({
        zone      => 'some states of the USA',
        countries => [ 'US' ],
        states    => [ 'CA', 'PA' ],
    });

If there is only a single country or state the value can be a scalar instead of a hashref.

=cut

sub new {
    my ( $class, $attrs ) = @_;

    my ( $countries, $states, $new );

    if ( $attrs->{countries} ) {
        if ( ref($attrs->{countries}) eq 'ARRAY' ) {
            push @$countries, @{$attrs->{countries}};
        }
        else {
            push @$countries, $attrs->{countries};
        }
        delete $attrs->{countries};

        if ( $attrs->{states} ) {
            if ( ref($attrs->{states}) eq 'ARRAY' ) {
                push @$states, @{$attrs->{states}};
            }
            else {
                push @$states, $attrs->{states};
            }
            delete $attrs->{states};
        }
    }
    elsif ( $attrs->{states} ) {
        die "Cannot create Zone with states but without countries";
    }

    $new = $class->next::method($attrs);
    $new->add_countries($countries) if $countries;
    $new->add_states($states) if $states;

    return $new;
}

=head2 add_countries

Argument is one of:

=over 4

=item an L<Interchange6::Schema::Result::Country> object

=item a country ISO code

=item an arrayref of the above (can include a mixture of both)

=back

Returns the zone object on success.

=cut

# add/remove_countries can be passed all sorts of junk but we need Country obj

sub _get_country_obj {

    my ( $self, $country ) = @_;

    if ( !defined $country ) {
        $self->throw_exception("Country must be defined");
    }
    elsif ( blessed($country) ){

        my $class = ref($country);

        $self->throw_exception("Country cannot be a $class")
            unless $country->isa('Interchange6::Schema::Result::Country');

    }
    elsif ( $country =~ m/^[a-z]{2}$/i ) {

        my $result = $self->result_source->schema->resultset("Country")
            ->find( { country_iso_code => uc($country) } );

        $self->throw_exception("No country found for code: $country")
            unless defined $result;

        $country = $result;
    }
    else {
        $self->throw_exception("Bad country: $country");
    }

    return $country;
}

sub add_countries {
    my ( $self, $arg ) = ( shift, shift );

    my $schema = $self->result_source->schema;

    if ( $self->state_count > 0 ) {
        $self->throw_exception(
            "Cannot add countries to zone containing states");
    }
    elsif ( ref($arg) ne "ARRAY" ) {

        # we need an arrayref
        $arg = [$arg];
    }

    # use a transaction when adding countries so that all succeed or all fail

    my $guard = $schema->txn_scope_guard;

    foreach my $country (@$arg) {

        $country = $self->_get_country_obj( $country );

        if ( $self->has_country($country) ) {
            $self->throw_exception(
                "Zone already includes country: " . $country->name );
        }

        $self->add_to_countries($country);
    }

    $guard->commit;

    return $self;
}

=head2 has_country

Argument can be Interchange6::Schema::Result::Country, country name or iso code. Returns 1 if zone includes that country else 0;

=cut

sub has_country {
    my ( $self, $country ) = ( shift, shift );
    my $rset;

    # first try Country object

    if ( blessed($country) ) {
        if ( $country->isa('Interchange6::Schema::Result::Country') ) {

            $rset = $self->countries->search(
                { "country.country_iso_code" => $country->country_iso_code, } );
            return 1 if $rset->count == 1;

        }
        else {
            return 0;
        }
    }
    else {

        # maybe an ISO code?

        if ( $country =~ /^[a-z]{2}$/i ) {

            $rset = $self->countries->search(
                { "country.country_iso_code" => uc($country) } );

            return 1 if $rset->count == 1;
        }
        else {

            # finally try country name

            $rset = $self->countries->search( { "country.name" => $country } );

            return 1 if $rset->count == 1;
        }
    }

    # failed to find the country
    return 0;
}

=head2 country_count

Takes no args. Returns the number of countries in the zone.

=cut

sub country_count {
    my $self = shift;
    return $self->countries->count;
}

=head2 remove_countries

Argument is either a L<Interchange6::Schema::Result::Country> object or an arrayref of the same.

Throws an exception on failure.

=cut

sub remove_countries {
    my ( $self, $arg ) = ( shift, shift );

    my $schema = $self->result_source->schema;

    if ( $self->state_count > 0 ) {

        $self->throw_exception("States must be removed before countries");

    }
    elsif ( ref($arg) ne "ARRAY" ) {

        # convert to arrayref
        $arg = [$arg];
    }

    # use a transaction when removing countries so that all succeed or all fail

    my $guard = $schema->txn_scope_guard;

    foreach my $country (@$arg) {

        $country = $self->_get_country_obj( $country );

        unless ( $self->has_country($country) ) {
            $self->throw_exception(
                "Country does not exist in zone: " . $country->name );
        }

        $self->remove_from_countries($country);
    }

    $guard->commit;

    return $self;
}

=head2 add_states

Argument is one of:

=over 4

=item an L<Interchange6::Schema::Result::State> object

=item a state ISO code

=item an arrayref of the above (can include a mixture of both)

=back

Returns the zone object on success.

=cut

# add/remove_states can be passed all sorts of junk but we need State obj

sub _get_state_obj {

    my ( $self, $state ) = @_;

    if ( !defined $state ) {
        $self->throw_exception("State must be defined");
    }
    elsif ( blessed($state) ){

        my $class = ref($state);

        $self->throw_exception("State cannot be a $class")
            unless $state->isa('Interchange6::Schema::Result::State');

    }
    elsif ( $state =~ m/^[a-z]{2}$/i ) {

        if ( $self->country_count == 1 ) {

            my $country = $self->countries->first;

            my $result = $self->result_source->schema->resultset("State")->find(
                {
                    country_iso_code => $country->country_iso_code,
                    state_iso_code   => uc($state)
                }
            );

            $self->throw_exception("No state found for code: $state")
                unless defined $result;

            $state = $result;

        }
        elsif ( $self->country_count == 0 ) {

            $self->throw_exception(
                "Cannot resolve state_iso_code for zone with no country"
            );
        }
        else {

            $self->throw_exception(
                "Cannot resolve state_iso_code for zone with > 1 country"
            );
        }
    }
    else {
        $self->throw_exception("Bad state: $state");
    }

    return $state;
}

sub add_states {
    my ( $self, $arg ) = ( shift, shift );

    my $schema = $self->result_source->schema;

    if ( $self->country_count > 1 ) {

        $self->throw_exception(
            "Cannot add state to zone with multiple countries");
    }
    elsif ( ref($arg) ne "ARRAY" ) {

        # we need an arayref
        $arg = [$arg];
    }

    # use a transaction when adding states so that all succeed or all fail

    my $guard = $schema->txn_scope_guard;

    foreach my $state (@$arg) {

        $state = $self->_get_state_obj( $state );

        if ( $self->country_count == 0 ) {

            # add the country first

            $self->add_countries( $state->country );
        }
        else {

            # make sure state is in the existing country

            my $country =
              $self->countries->search( {}, { rows => 1 } )->single;

            unless ( $country->country_iso_code eq
                $state->country->country_iso_code )
            {
                $self->throw_exception( "State "
                      . $state->name
                      . " is not in country "
                      . $country->name );
                next;
            }
        }

        if ( $self->has_state($state) ) {
            $self->throw_exception(
                "Zone already includes state: " . $state->name );
            next;
        }

        # try to add the state

        $self->add_to_states($state);
    }

    $guard->commit;

    return $self;
}

=head2 has_state

Argument can be Interchange6::Schema::Result::State, state name or iso code. Returns 1 if zone includes that state else 0;

=cut

sub has_state {
    my ( $self, $state ) = ( shift, shift );
    my $rset;

    # first try State object

    if ( blessed($state) ) {
        if ( $state->isa('Interchange6::Schema::Result::State') ) {

            $rset = $self->states->search(
                {
                    "state.country_iso_code" => $state->country_iso_code,
                    "state.state_iso_code"   => $state->state_iso_code
                }
            );
            return 1 if $rset->count == 1;

        }
        else {
            return 0;
        }
    }
    else {

        # maybe an ISO code?

        if ( $state =~ /^[a-z]{2}$/i ) {

            $rset = $self->states->search( { state_iso_code => uc($state) } );

            return 1 if $rset->count == 1;
        }
        else {

            # finally try state name

            $rset = $self->states->search( { name => $state } );

            return 1 if $rset->count == 1;

        }
    }

    # failed to find the state
    return 0;
}

=head2 state_count

Takes no args. Returns the number of states in the zone.

=cut

sub state_count {
    my $self = shift;
    return $self->states->search( {} )->count;
}

=head2 remove_states

Argument is either a L<Interchange6::Schema::Result::State> object or an arrayref of the same.

Returns the Zone object or undef on failure. Errors are available via errors method inherited from L<Interchange6::Schema::Role::Errors>.

=cut

sub remove_states {
    my ( $self, $arg ) = ( shift, shift );

    my $schema = $self->result_source->schema;

    if ( ref($arg) ne "ARRAY" ) {

        # we need an arrayref
        $arg = [$arg];
    }

    # use a transaction when removing states so that all succeed or all fail

    my $guard = $schema->txn_scope_guard;

    foreach my $state (@$arg) {

        $state = $self->_get_state_obj( $state );

        $self->remove_from_states($state);
    }

    $guard->commit;

    return $self;
}

1;