add svc_phone.sim_imsi, RT#20768
[freeside.git] / FS / FS / svc_phone.pm
1 package FS::svc_phone;
2
3 use strict;
4 use base qw( FS::svc_Domain_Mixin FS::location_Mixin FS::svc_Common );
5 use vars qw( $DEBUG $me @pw_set $conf $phone_name_max );
6 use Data::Dumper;
7 use Scalar::Util qw( blessed );
8 use FS::Conf;
9 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::Msgcat qw(gettext);
11 use FS::part_svc;
12 use FS::phone_device;
13 use FS::svc_pbx;
14 use FS::svc_domain;
15 use FS::cust_location;
16 use FS::phone_avail;
17
18 $me = '[' . __PACKAGE__ . ']';
19 $DEBUG = 0;
20
21 #avoid l 1 and o O 0
22 @pw_set = ( 'a'..'k', 'm','n', 'p-z', 'A'..'N', 'P'..'Z' , '2'..'9' );
23
24 #ask FS::UID to run this stuff for us later
25 FS::UID->install_callback( sub { 
26   $conf = new FS::Conf;
27   $phone_name_max = $conf->config('svc_phone-phone_name-max_length');
28 }
29 );
30
31 =head1 NAME
32
33 FS::svc_phone - Object methods for svc_phone records
34
35 =head1 SYNOPSIS
36
37   use FS::svc_phone;
38
39   $record = new FS::svc_phone \%hash;
40   $record = new FS::svc_phone { 'column' => 'value' };
41
42   $error = $record->insert;
43
44   $error = $new_record->replace($old_record);
45
46   $error = $record->delete;
47
48   $error = $record->check;
49
50   $error = $record->suspend;
51
52   $error = $record->unsuspend;
53
54   $error = $record->cancel;
55
56 =head1 DESCRIPTION
57
58 An FS::svc_phone object represents a phone number.  FS::svc_phone inherits
59 from FS::Record.  The following fields are currently supported:
60
61 =over 4
62
63 =item svcnum
64
65 primary key
66
67 =item countrycode
68
69 =item phonenum
70
71 =item sim_imsi
72
73 SIM IMSI (http://en.wikipedia.org/wiki/International_mobile_subscriber_identity)
74
75 =item sip_password
76
77 =item pin
78
79 Voicemail PIN
80
81 =item phone_name
82
83 =item pbxsvc
84
85 Optional svcnum from svc_pbx
86
87 =item forwarddst
88
89 Forwarding destination
90
91 =item email
92
93 Email address for virtual fax (fax-to-email) services
94
95 =item lnp_status
96
97 LNP Status (can be null, native, portedin, portingin, portin-reject,
98 portingout, portout-reject)
99
100 =item portable
101
102 =item lrn
103
104 =item lnp_desired_due_date
105
106 =item lnp_due_date
107
108 =item lnp_other_provider
109
110 If porting the number in or out, name of the losing or winning provider, 
111 respectively.
112
113 =item lnp_other_provider_account
114
115 Account number of other provider. See lnp_other_provider.
116
117 =item lnp_reject_reason
118
119 See lnp_status. If lnp_status is portin-reject or portout-reject, this is an
120 optional reject reason.
121
122 =back
123
124 =head1 METHODS
125
126 =over 4
127
128 =item new HASHREF
129
130 Creates a new phone number.  To add the number to the database, see L<"insert">.
131
132 Note that this stores the hash reference, not a distinct copy of the hash it
133 points to.  You can ask the object for a copy with the I<hash> method.
134
135 =cut
136
137 # the new method can be inherited from FS::Record, if a table method is defined
138 #
139 sub table_info {
140  my %dis2 = ( disable_inventory=>1, disable_select=>1 );
141   {
142     'name' => 'Phone number',
143     'sorts' => 'phonenum',
144     'display_weight' => 60,
145     'cancel_weight'  => 80,
146     'fields' => {
147         'svcnum'       => 'Service',
148         'countrycode'  => { label => 'Country code',
149                             type  => 'text',
150                             disable_inventory => 1,
151                             disable_select => 1,
152                           },
153         'phonenum'     => 'Phone number',
154         'sim_imsi'     => 'IMSI', #http://en.wikipedia.org/wiki/International_mobile_subscriber_identity
155         'pin'          => { label => 'Voicemail PIN', #'Personal Identification Number',
156                             type  => 'text',
157                             disable_inventory => 1,
158                             disable_select => 1,
159                           },
160         'sip_password' => 'SIP password',
161         'phone_name'   => 'Name',
162         'pbxsvc'       => { label => 'PBX',
163                             type  => 'select-svc_pbx.html',
164                             disable_inventory => 1,
165                             disable_select => 1, #UI wonky, pry works otherwise
166                           },
167         'domsvc'    => {
168                          label     => 'Domain',
169                          type      => 'select',
170                          select_table => 'svc_domain',
171                          select_key   => 'svcnum',
172                          select_label => 'domain',
173                          disable_inventory => 1,
174                        },
175         'locationnum' => {
176                            label => 'E911 location',
177                            disable_inventory => 1,
178                            disable_select    => 1,
179                          },
180         'forwarddst' => {       label => 'Forward Destination', 
181                                 %dis2,
182                         },
183         'email' => {            label => 'Email',
184                                 %dis2,
185                     },
186         'lnp_status' => {       label => 'LNP Status',
187                                 type => 'select-lnp_status.html',
188                                 %dis2,
189                         },
190         'lnp_reject_reason' => { 
191                                 label => 'LNP Reject Reason',
192                                 %dis2,
193                         },
194         'portable' =>   {       label => 'Portable?', %dis2, },
195         'lrn'   =>      {       label => 'LRN', 
196                                 disable_inventory => 1, 
197                         },
198         'lnp_desired_due_date' =>
199                         { label => 'LNP Desired Due Date', %dis2 },
200         'lnp_due_date' =>
201                         { label => 'LNP Due Date', %dis2 },
202         'lnp_other_provider' =>
203                         {       label => 'LNP Other Provider', 
204                                 disable_inventory => 1, 
205                         },
206         'lnp_other_provider_account' =>
207                         {       label => 'LNP Other Provider Account #', 
208                                 %dis2 
209                         },
210     },
211   };
212 }
213
214 sub table { 'svc_phone'; }
215
216 sub table_dupcheck_fields { ( 'countrycode', 'phonenum' ); }
217
218 =item search_sql STRING
219
220 Class method which returns an SQL fragment to search for the given string.
221
222 =cut
223
224 sub search_sql {
225   my( $class, $string ) = @_;
226
227   if ( $conf->exists('svc_phone-allow_alpha_phonenum') ) {
228     $string =~ s/\W//g;
229   } else {
230     $string =~ s/\D//g;
231   }
232
233   my $conf = new FS::Conf;
234   my $ccode = (    $conf->exists('default_phone_countrycode')
235                 && $conf->config('default_phone_countrycode')
236               )
237                 ? $conf->config('default_phone_countrycode') 
238                 : '1';
239
240   $string =~ s/^$ccode//;
241
242   $class->search_sql_field('phonenum', $string );
243 }
244
245 =item label
246
247 Returns the phone number.
248
249 =cut
250
251 sub label {
252   my $self = shift;
253   my $phonenum = $self->phonenum; #XXX format it better
254   my $label = $phonenum;
255   $label .= '@'.$self->domain if $self->domsvc;
256   $label .= ' ('.$self->phone_name.')' if $self->phone_name;
257   $label;
258 }
259
260 =item insert
261
262 Adds this phone number to the database.  If there is an error, returns the
263 error, otherwise returns false.
264
265 =cut
266
267 sub insert {
268   my $self = shift;
269   my %options = @_;
270
271   if ( $DEBUG ) {
272     warn "[$me] insert called on $self: ". Dumper($self).
273          "\nwith options: ". Dumper(%options);
274   }
275
276   local $SIG{HUP} = 'IGNORE';
277   local $SIG{INT} = 'IGNORE';
278   local $SIG{QUIT} = 'IGNORE';
279   local $SIG{TERM} = 'IGNORE';
280   local $SIG{TSTP} = 'IGNORE';
281   local $SIG{PIPE} = 'IGNORE';
282
283   my $oldAutoCommit = $FS::UID::AutoCommit;
284   local $FS::UID::AutoCommit = 0;
285   my $dbh = dbh;
286
287   #false laziness w/cust_pkg.pm... move this to location_Mixin?  that would
288   #make it more of a base class than a mixin... :)
289   if ( $options{'cust_location'}
290          && ( ! $self->locationnum || $self->locationnum == -1 ) ) {
291     my $error = $options{'cust_location'}->insert;
292     if ( $error ) {
293       $dbh->rollback if $oldAutoCommit;
294       return "inserting cust_location (transaction rolled back): $error";
295     }
296     $self->locationnum( $options{'cust_location'}->locationnum );
297   }
298   #what about on-the-fly edits?  if the ui supports it?
299
300   my $error = $self->SUPER::insert(%options);
301   if ( $error ) {
302     $dbh->rollback if $oldAutoCommit;
303     return $error;
304   }
305
306   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
307   '';
308
309 }
310
311 =item delete
312
313 Delete this record from the database.
314
315 =cut
316
317 sub delete {
318   my $self = shift;
319
320   local $SIG{HUP} = 'IGNORE';
321   local $SIG{INT} = 'IGNORE';
322   local $SIG{QUIT} = 'IGNORE';
323   local $SIG{TERM} = 'IGNORE';
324   local $SIG{TSTP} = 'IGNORE';
325   local $SIG{PIPE} = 'IGNORE';
326
327   my $oldAutoCommit = $FS::UID::AutoCommit;
328   local $FS::UID::AutoCommit = 0;
329   my $dbh = dbh;
330
331   foreach my $phone_device ( $self->phone_device ) {
332     my $error = $phone_device->delete;
333     if ( $error ) {
334       $dbh->rollback if $oldAutoCommit;
335       return $error;
336     }
337   }
338
339   my @phone_avail = qsearch('phone_avail', { 'svcnum' => $self->svcnum } );
340   foreach my $phone_avail ( @phone_avail ) {
341     $phone_avail->svcnum('');
342     my $error = $phone_avail->replace;
343     if ( $error ) {
344       $dbh->rollback if $oldAutoCommit;
345       return $error;
346     }
347   }
348
349   my $error = $self->SUPER::delete;
350   if ( $error ) {
351     $dbh->rollback if $oldAutoCommit;
352     return $error;
353   }
354
355   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
356   '';
357
358 }
359
360 # the delete method can be inherited from FS::Record
361
362 =item replace OLD_RECORD
363
364 Replaces the OLD_RECORD with this one in the database.  If there is an error,
365 returns the error, otherwise returns false.
366
367 =cut
368
369 sub replace {
370   my $new = shift;
371
372   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
373               ? shift
374               : $new->replace_old;
375
376   my %options = @_;
377
378   if ( $DEBUG ) {
379     warn "[$me] replacing $old with $new\n".
380          "\nwith options: ". Dumper(%options);
381   }
382
383   local $SIG{HUP} = 'IGNORE';
384   local $SIG{INT} = 'IGNORE';
385   local $SIG{QUIT} = 'IGNORE';
386   local $SIG{TERM} = 'IGNORE';
387   local $SIG{TSTP} = 'IGNORE';
388   local $SIG{PIPE} = 'IGNORE';
389
390   my $oldAutoCommit = $FS::UID::AutoCommit;
391   local $FS::UID::AutoCommit = 0;
392   my $dbh = dbh;
393
394   #false laziness w/cust_pkg.pm... move this to location_Mixin?  that would
395   #make it more of a base class than a mixin... :)
396   if ( $options{'cust_location'}
397          && ( ! $new->locationnum || $new->locationnum == -1 ) ) {
398     my $error = $options{'cust_location'}->insert;
399     if ( $error ) {
400       $dbh->rollback if $oldAutoCommit;
401       return "inserting cust_location (transaction rolled back): $error";
402     }
403     $new->locationnum( $options{'cust_location'}->locationnum );
404   }
405   #what about on-the-fly edits?  if the ui supports it?
406
407   # LNP data validation
408  return 'Invalid LNP status' # if someone does really stupid stuff
409     if (  ($old->lnp_status eq 'portingout' && $new->lnp_status eq 'portingin')
410         || ($old->lnp_status eq 'portout-reject' && $new->lnp_status eq 'portingin')
411         || ($old->lnp_status eq 'portin-reject' && $new->lnp_status eq 'portingout')
412         || ($old->lnp_status eq 'portingin' && $new->lnp_status eq 'native')
413         || ($old->lnp_status eq 'portin-reject' && $new->lnp_status eq 'native')
414         || ($old->lnp_status eq 'portingin' && $new->lnp_status eq 'portingout')
415         || ($old->lnp_status eq 'portingout' && $new->lnp_status eq 'portin-reject')
416         );
417
418   my $error = $new->SUPER::replace($old, %options);
419   if ( $error ) {
420     $dbh->rollback if $oldAutoCommit;
421     return $error if $error;
422   }
423
424   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
425   ''; #no error
426 }
427
428 =item suspend
429
430 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
431
432 =item unsuspend
433
434 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
435
436 =item cancel
437
438 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
439
440 =item check
441
442 Checks all fields to make sure this is a valid phone number.  If there is
443 an error, returns the error, otherwise returns false.  Called by the insert
444 and replace methods.
445
446 =cut
447
448 # the check method should currently be supplied - FS::Record contains some
449 # data checking routines
450
451 sub check {
452   my $self = shift;
453
454   my $conf = new FS::Conf;
455
456   my $phonenum = $self->phonenum;
457   my $phonenum_check_method;
458   if ( $conf->exists('svc_phone-allow_alpha_phonenum') ) {
459     $phonenum =~ s/\W//g;
460     $phonenum_check_method = 'ut_alpha';
461   } else {
462     $phonenum =~ s/\D//g;
463     $phonenum_check_method = 'ut_number';
464   }
465   $self->phonenum($phonenum);
466
467   $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
468
469   my $error = 
470     $self->ut_numbern('svcnum')
471     || $self->ut_numbern('countrycode')
472     || $self->$phonenum_check_method('phonenum')
473     || $self->ut_numbern('sim_imsi')
474     || $self->ut_anything('sip_password')
475     || $self->ut_numbern('pin')
476     || $self->ut_textn('phone_name')
477     || $self->ut_foreign_keyn('pbxsvc', 'svc_pbx',    'svcnum' )
478     || $self->ut_foreign_keyn('domsvc', 'svc_domain', 'svcnum' )
479     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
480     || $self->ut_numbern('forwarddst')
481     || $self->ut_textn('email')
482     || $self->ut_numbern('lrn')
483     || $self->ut_numbern('lnp_desired_due_date')
484     || $self->ut_numbern('lnp_due_date')
485     || $self->ut_textn('lnp_other_provider')
486     || $self->ut_textn('lnp_other_provider_account')
487     || $self->ut_enumn('lnp_status', ['','portingin','portingout','portedin',
488                                 'native', 'portin-reject', 'portout-reject'])
489     || $self->ut_enumn('portable', ['','Y'])
490     || $self->ut_textn('lnp_reject_reason')
491   ;
492   return $error if $error;
493
494   return 'Illegal IMSI (not 14-15 digits)' #shorter?
495     if length($self->sim_imsi)
496     && ( length($self->sim_imsi) < 14 || length($self->sim_imsi) > 15 );
497
498     # LNP data validation
499     return 'Cannot set LNP fields: no LNP in progress'
500         if ( ($self->lnp_desired_due_date || $self->lnp_due_date 
501             || $self->lnp_other_provider || $self->lnp_other_provider_account
502             || $self->lnp_reject_reason) 
503             && (!$self->lnp_status || $self->lnp_status eq 'native') );
504     return 'Cannot set LNP reject reason: no LNP in progress or status is not reject'
505         if ($self->lnp_reject_reason && (!$self->lnp_status 
506                             || $self->lnp_status !~ /^port(in|out)-reject$/) );
507     return 'Cannot port-out a non-portable number' 
508         if (!$self->portable && $self->lnp_status eq 'portingout');
509
510
511   return 'Name ('. $self->phone_name.
512          ") is longer than $phone_name_max characters"
513     if $phone_name_max && length($self->phone_name) > $phone_name_max;
514
515   $self->countrycode(1) unless $self->countrycode;
516
517   unless ( length($self->pin) ) {
518     my $random_pin = $conf->config('svc_phone-random_pin');
519     if ( $random_pin =~ /^\d+$/ ) {
520       $self->pin(
521         join('', map int(rand(10)), 0..($random_pin-1))
522       );
523     }
524   }
525
526   unless ( length($self->sip_password) ) { # option for this?
527
528     $self->sip_password(
529       join('', map $pw_set[ int(rand $#pw_set) ], (0..16) )
530     );
531
532   }
533
534   $self->SUPER::check;
535 }
536
537 =item _check duplicate
538
539 Internal method to check for duplicate phone numers.
540
541 =cut
542
543 #false laziness w/svc_acct.pm's _check_duplicate.
544 sub _check_duplicate {
545   my $self = shift;
546
547   my $global_unique = $conf->config('global_unique-phonenum') || 'none';
548   return '' if $global_unique eq 'disabled';
549
550   $self->lock_table;
551
552   my @dup_ccphonenum =
553     grep { !$self->svcnum || $_->svcnum != $self->svcnum }
554     qsearch( 'svc_phone', {
555       'countrycode' => $self->countrycode,
556       'phonenum'    => $self->phonenum,
557     });
558
559   return gettext('phonenum_in_use')
560     if $global_unique eq 'countrycode+phonenum' && @dup_ccphonenum;
561
562   my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
563   unless ( $part_svc ) {
564     return 'unknown svcpart '. $self->svcpart;
565   }
566
567   if ( @dup_ccphonenum ) {
568
569     my $exports = FS::part_export::export_info('svc_phone');
570     my %conflict_ccphonenum_svcpart = ( $self->svcpart => 'SELF', );
571
572     foreach my $part_export ( $part_svc->part_export ) {
573
574       #this will catch to the same exact export
575       my @svcparts = map { $_->svcpart } $part_export->export_svc;
576
577       $conflict_ccphonenum_svcpart{$_} = $part_export->exportnum
578         foreach @svcparts;
579
580     }
581
582     foreach my $dup_ccphonenum ( @dup_ccphonenum ) {
583       my $dup_svcpart = $dup_ccphonenum->cust_svc->svcpart;
584       if ( exists($conflict_ccphonenum_svcpart{$dup_svcpart}) ) {
585         return "duplicate phone number ".
586                $self->countrycode. ' '. $self->phonenum.
587                ": conflicts with svcnum ". $dup_ccphonenum->svcnum.
588                " via exportnum ". $conflict_ccphonenum_svcpart{$dup_svcpart};
589       }
590     }
591
592   }
593
594   return '';
595
596 }
597
598 =item check_pin
599
600 Checks the supplied PIN against the PIN in the database.  Returns true for a
601 sucessful authentication, false if no match.
602
603 =cut
604
605 sub check_pin {
606   my($self, $check_pin) = @_;
607   length($self->pin) && $check_pin eq $self->pin;
608 }
609
610 =item radius_reply
611
612 =cut
613
614 sub radius_reply {
615   my $self = shift;
616   #XXX Session-Timeout!  holy shit, need rlm_perl to ask for this in realtime
617   ();
618 }
619
620 =item radius_check
621
622 =cut
623
624 sub radius_check {
625   my $self = shift;
626   my %check = ();
627
628   my $conf = new FS::Conf;
629
630   $check{'User-Password'} = $conf->config('svc_phone-radius-default_password');
631
632   %check;
633 }
634
635 sub radius_groups {
636   ();
637 }
638
639 =item phone_device
640
641 Returns any FS::phone_device records associated with this service.
642
643 =cut
644
645 sub phone_device {
646   my $self = shift;
647   qsearch('phone_device', { 'svcnum' => $self->svcnum } );
648 }
649
650 #override location_Mixin version cause we want to try the cust_pkg location
651 #in between us and cust_main
652 # XXX what to do in the unlinked case???  return a pseudo-object that returns
653 # empty fields?
654 sub cust_location_or_main {
655   my $self = shift;
656   return $self->cust_location if $self->locationnum;
657   my $cust_pkg = $self->cust_svc->cust_pkg;
658   $cust_pkg ? $cust_pkg->cust_location_or_main : '';
659 }
660
661 =item get_cdrs
662
663 Returns a set of Call Detail Records (see L<FS::cdr>) associated with this 
664 service.  By default, "associated with" means that either the "src" or the 
665 "charged_party" field of the CDR matches the "phonenum" field of the service.
666
667 =over 2
668
669 Accepts the following options:
670
671 =item for_update => 1: SELECT the CDRs "FOR UPDATE".
672
673 =item status => "" (or "processing-tiered", "done"): Return only CDRs with that processing status.
674
675 =item inbound => 1: Return CDRs for inbound calls.  With "status", will filter 
676 on inbound processing status.
677
678 =item default_prefix => "XXX": Also accept the phone number of the service prepended 
679 with the chosen prefix.
680
681 =item begin, end: Start and end of a date range, as unix timestamp.
682
683 =item cdrtypenum: Only return CDRs with this type number.
684
685 =item disable_src => 1: Only match on "charged_party", not "src".
686
687 =item by_svcnum: not supported for svc_phone
688
689 =back
690
691 =cut
692
693 sub get_cdrs {
694   my($self, %options) = @_;
695   my @fields;
696   my %hash;
697   my @where;
698
699   if ( $options{'inbound'} ) {
700
701     @fields = ( 'dst' );
702     if ( exists($options{'status'}) ) {
703       my $status = $options{'status'};
704       if ( $status ) {
705         push @where, 'EXISTS ( SELECT 1 FROM cdr_termination '.
706           'WHERE cdr.acctid = cdr_termination.acctid '.
707           "AND cdr_termination.status = '$status' ". #quoting kludge
708           'AND cdr_termination.termpart = 1 )';
709       } else {
710         push @where, 'NOT EXISTS ( SELECT 1 FROM cdr_termination '.
711           'WHERE cdr.acctid = cdr_termination.acctid '.
712           'AND cdr_termination.termpart = 1 )';
713       }
714     }
715
716   } else {
717
718     @fields = ( 'charged_party' );
719     push @fields, 'src' if !$options{'disable_src'};
720     $hash{'freesidestatus'} = $options{'status'}
721       if exists($options{'status'});
722   }
723
724   if ($options{'cdrtypenum'}) {
725     $hash{'cdrtypenum'} = $options{'cdrtypenum'};
726   }
727   
728   my $for_update = $options{'for_update'} ? 'FOR UPDATE' : '';
729
730   my $number = $self->phonenum;
731
732   my $prefix = $options{'default_prefix'};
733
734   my @orwhere =  map " $_ = '$number'        ", @fields;
735   push @orwhere, map " $_ = '$prefix$number' ", @fields
736     if defined($prefix) && length($prefix);
737   if ( $prefix && $prefix =~ /^\+(\d+)$/ ) {
738     push @orwhere, map " $_ = '$1$number' ", @fields
739   }
740
741   push @where, ' ( '. join(' OR ', @orwhere ). ' ) ';
742
743   if ( $options{'begin'} ) {
744     push @where, 'startdate >= '. $options{'begin'};
745   }
746   if ( $options{'end'} ) {
747     push @where, 'startdate < '.  $options{'end'};
748   }
749
750   my $extra_sql = ( keys(%hash) ? ' AND ' : ' WHERE ' ). join(' AND ', @where );
751
752   my @cdrs =
753     qsearch( {
754       'table'      => 'cdr',
755       'hashref'    => \%hash,
756       'extra_sql'  => $extra_sql,
757       'order_by'   => $options{'billsec_sum'} ? '' : "ORDER BY startdate $for_update",
758       'select'     => $options{'billsec_sum'} ? 'sum(billsec) as billsec_sum' : '*',
759     } );
760
761   @cdrs;
762 }
763
764 =back
765
766 =head1 BUGS
767
768 =head1 SEE ALSO
769
770 L<FS::svc_Common>, L<FS::Record>, L<FS::cust_svc>, L<FS::part_svc>,
771 L<FS::cust_pkg>, schema.html from the base documentation.
772
773 =cut
774
775 1;
776