477 report rewrite, #28020
[freeside.git] / FS / FS / Upgrade.pm
1 package FS::Upgrade;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $DEBUG );
5 use Exporter;
6 use Tie::IxHash;
7 use File::Slurp;
8 use FS::UID qw( dbh driver_name );
9 use FS::Conf;
10 use FS::Record qw(qsearchs qsearch str2time_sql);
11 use FS::queue;
12 use FS::upgrade_journal;
13
14 use FS::svc_domain;
15 $FS::svc_domain::whois_hack = 1;
16
17 @ISA = qw( Exporter );
18 @EXPORT_OK = qw( upgrade_schema upgrade_config upgrade upgrade_sqlradius );
19
20 $DEBUG = 1;
21
22 =head1 NAME
23
24 FS::Upgrade - Database upgrade routines
25
26 =head1 SYNOPSIS
27
28   use FS::Upgrade;
29
30 =head1 DESCRIPTION
31
32 Currently this module simply provides a place to store common subroutines for
33 database upgrades.
34
35 =head1 SUBROUTINES
36
37 =over 4
38
39 =item upgrade_config
40
41 =cut
42
43 #config upgrades
44 sub upgrade_config {
45   my %opt = @_;
46
47   my $conf = new FS::Conf;
48
49   $conf->touch('payment_receipt')
50     if $conf->exists('payment_receipt_email')
51     || $conf->config('payment_receipt_msgnum');
52
53   $conf->touch('geocode-require_nw_coordinates')
54     if $conf->exists('svc_broadband-require-nw-coordinates');
55
56   unless ( $conf->config('echeck-country') ) {
57     if ( $conf->exists('cust_main-require-bank-branch') ) {
58       $conf->set('echeck-country', 'CA');
59     } elsif ( $conf->exists('echeck-nonus') ) {
60       $conf->set('echeck-country', 'XX');
61     } else {
62       $conf->set('echeck-country', 'US');
63     }
64   }
65
66   upgrade_overlimit_groups($conf);
67   map { upgrade_overlimit_groups($conf,$_->agentnum) } qsearch('agent', {});
68
69   my $DIST_CONF = '/usr/local/etc/freeside/default_conf/';#DIST_CONF in Makefile
70   $conf->set($_, scalar(read_file( "$DIST_CONF/$_" )) )
71     foreach grep { ! $conf->exists($_) && -s "$DIST_CONF/$_" }
72       qw( quotation_html quotation_latex quotation_latexnotes );
73
74   # change 'fslongtable' to 'longtable'
75   # in invoice and quotation main templates, and also in all secondary 
76   # invoice templates
77   my @latex_confs =
78     qsearch('conf', { 'name' => {op=>'LIKE', value=>'%latex%'} });
79
80   foreach my $c (@latex_confs) {
81     my $value = $c->value;
82     if (length($value) and $value =~ /fslongtable/) {
83       $value =~ s/fslongtable/longtable/g;
84       $conf->set($c->name, $value, $c->agentnum);
85     }
86   }
87
88   # if there's a USPS tools login, assume that's the standardization method
89   # you want to use
90   $conf->set('address_standardize_method', 'usps')
91     if $conf->exists('usps_webtools-userid')
92     && length($conf->config('usps_webtools-userid')) > 0
93     && ! $conf->exists('address_standardize_method');
94
95   # this option has been renamed/expanded
96   if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
97     $conf->touch('cust_main-enable_spouse');
98     $conf->delete('cust_main-enable_spouse_birthdate');
99   }
100
101   # renamed/repurposed
102   if ( $conf->exists('cust_pkg-show_fcc_voice_grade_equivalent') ) {
103     $conf->touch('part_pkg-show_fcc_options');
104     $conf->delete('cust_pkg-show_fcc_voice_grade_equivalent');
105     warn "
106 You have FCC Form 477 package options enabled.
107
108 Starting with the October 2014 filing date, the FCC has redesigned 
109 Form 477 and introduced new service categories.  See bin/convert-477-options
110 to update your package configuration for the new report.
111
112 If you need to continue using the old Form 477 report, turn on the
113 'old_fcc_report' configuration option.
114 ";
115   }
116 }
117
118 sub upgrade_overlimit_groups {
119     my $conf = shift;
120     my $agentnum = shift;
121     my @groups = $conf->config('overlimit_groups',$agentnum); 
122     if(scalar(@groups)) {
123         my $groups = join(',',@groups);
124         my @groupnums;
125         my $error = '';
126         if ( $groups !~ /^[\d,]+$/ ) {
127             foreach my $groupname ( @groups ) {
128                 my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
129                 unless ( $g ) {
130                     $g = new FS::radius_group {
131                                     'groupname' => $groupname,
132                                     'description' => $groupname,
133                                     };
134                     $error = $g->insert;
135                     die $error if $error;
136                 }
137                 push @groupnums, $g->groupnum;
138             }
139             $conf->set('overlimit_groups',join("\n",@groupnums),$agentnum);
140         }
141     }
142 }
143
144 =item upgrade
145
146 =cut
147
148 sub upgrade {
149   my %opt = @_;
150
151   my $data = upgrade_data(%opt);
152
153   my $oldAutoCommit = $FS::UID::AutoCommit;
154   local $FS::UID::AutoCommit = 0;
155   local $FS::UID::AutoCommit = 0;
156
157   local $FS::cust_pkg::upgrade = 1; #go away after setup+start dates cleaned up for old customers
158
159
160   foreach my $table ( keys %$data ) {
161
162     my $class = "FS::$table";
163     eval "use $class;";
164     die $@ if $@;
165
166     if ( $class->can('_upgrade_data') ) {
167       warn "Upgrading $table...\n";
168
169       my $start = time;
170
171       $class->_upgrade_data(%opt);
172
173       # New interface for async upgrades: a class can declare a 
174       # "queueable_upgrade" method, which will run as part of the normal 
175       # upgrade, but if the -j option is passed, will instead be run from 
176       # the job queue.
177       if ( $class->can('queueable_upgrade') ) {
178         my $jobname = $class . '::queueable_upgrade';
179         my $num_jobs = FS::queue->count("job = '$jobname' and status != 'failed'");
180         if ($num_jobs > 0) {
181           warn "$class upgrade already scheduled.\n";
182         } else {
183           if ( $opt{'queue'} ) {
184             warn "Scheduling $class upgrade.\n";
185             my $job = FS::queue->new({ job => $jobname });
186             $job->insert($class, %opt);
187           } else {
188             $class->queueable_upgrade(%opt);
189           }
190         } #$num_jobs == 0
191       }
192
193       if ( $oldAutoCommit ) {
194         warn "  committing\n";
195         dbh->commit or die dbh->errstr;
196       }
197       
198       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
199       warn "  done in ". (time-$start). " seconds\n";
200
201     } else {
202       warn "WARNING: asked for upgrade of $table,".
203            " but FS::$table has no _upgrade_data method\n";
204     }
205
206 #    my @records = @{ $data->{$table} };
207 #
208 #    foreach my $record ( @records ) {
209 #      my $args = delete($record->{'_upgrade_args'}) || [];
210 #      my $object = $class->new( $record );
211 #      my $error = $object->insert( @$args );
212 #      die "error inserting record into $table: $error\n"
213 #        if $error;
214 #    }
215
216   }
217
218   local($FS::cust_main::ignore_expired_card) = 1;
219   local($FS::cust_main::ignore_illegal_zip) = 1;
220   local($FS::cust_main::ignore_banned_card) = 1;
221   local($FS::cust_main::skip_fuzzyfiles) = 1;
222
223   local($FS::cust_payby::ignore_expired_card) = 1;
224   local($FS::cust_payby::ignore_banned_card) = 1;
225
226   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
227   # kind of a weird spot for this, but it's better than duplicating
228   # all this code in each class...
229   my @decrypt_tables = qw( cust_main cust_pay_void cust_pay cust_refund cust_pay_pending );
230   foreach my $table ( @decrypt_tables ) {
231       my @objects = qsearch({
232         'table'     => $table,
233         'hashref'   => {},
234         'extra_sql' => "WHERE payby NOT IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
235                        " AND LENGTH(payinfo) > 100",
236       });
237       foreach my $object ( @objects ) {
238           my $payinfo = $object->decrypt($object->payinfo);
239           die "error decrypting payinfo" if $payinfo eq $object->payinfo;
240           $object->payinfo($payinfo);
241           my $error = $object->replace;
242           die $error if $error;
243       }
244   }
245
246 }
247
248 =item upgrade_data
249
250 =cut
251
252 sub upgrade_data {
253   my %opt = @_;
254
255   tie my %hash, 'Tie::IxHash', 
256
257     #cust_main (remove paycvv from history)
258     'cust_main' => [],
259
260     #msgcat
261     'msgcat' => [],
262
263     #reason type and reasons
264     'reason_type'     => [],
265     'cust_pkg_reason' => [],
266
267     #need part_pkg before cust_credit...
268     'part_pkg' => [],
269
270     #customer credits
271     'cust_credit' => [],
272
273     #duplicate history records
274     'h_cust_svc'  => [],
275
276     #populate cust_pay.otaker
277     'cust_pay'    => [],
278
279     #populate part_pkg_taxclass for starters
280     'part_pkg_taxclass' => [],
281
282     #remove bad pending records
283     'cust_pay_pending' => [],
284
285     #replace invnum and pkgnum with billpkgnum
286     'cust_bill_pkg_detail' => [],
287
288     #usage_classes if we have none
289     'usage_class' => [],
290
291     #phone_type if we have none
292     'phone_type' => [],
293
294     #fixup access rights
295     'access_right' => [],
296
297     #change recur_flat and enable_prorate
298     'part_pkg_option' => [],
299
300     #add weights to pkg_category
301     'pkg_category' => [],
302
303     #cdrbatch fixes
304     'cdr' => [],
305
306     #otaker->usernum
307     'cust_attachment' => [],
308     #'cust_credit' => [],
309     #'cust_main' => [],
310     'cust_main_note' => [],
311     #'cust_pay' => [],
312     'cust_pay_void' => [],
313     'cust_pkg' => [],
314     #'cust_pkg_reason' => [],
315     'cust_pkg_discount' => [],
316     'cust_refund' => [],
317     'banned_pay' => [],
318
319     #default namespace
320     'payment_gateway' => [],
321
322     #migrate to templates
323     'msg_template' => [],
324
325     #return unprovisioned numbers to availability
326     'phone_avail' => [],
327
328     #insert scripcondition
329     'TicketSystem' => [],
330     
331     #insert LATA data if not already present
332     'lata' => [],
333     
334     #insert MSA data if not already present
335     'msa' => [],
336
337     # migrate to radius_group and groupnum instead of groupname
338     'radius_usergroup' => [],
339     'part_svc'         => [],
340     'part_export'      => [],
341
342     #insert default tower_sector if not present
343     'tower' => [],
344
345     #repair improperly deleted services
346     'cust_svc' => [],
347
348     #routernum/blocknum
349     'svc_broadband' => [],
350
351     #set up payment gateways if needed
352     'pay_batch' => [],
353
354     #flag monthly tax exemptions
355     'cust_tax_exempt_pkg' => [],
356
357     #kick off tax location history upgrade
358     'cust_bill_pkg' => [],
359
360     #fix taxable line item links
361     'cust_bill_pkg_tax_location' => [],
362
363     #populate state FIPS codes if not already done
364     'state' => [],
365   ;
366
367   \%hash;
368
369 }
370
371 =item upgrade_schema
372
373 =cut
374
375 sub upgrade_schema {
376   my %opt = @_;
377
378   my $data = upgrade_schema_data(%opt);
379
380   my $oldAutoCommit = $FS::UID::AutoCommit;
381   local $FS::UID::AutoCommit = 0;
382   local $FS::UID::AutoCommit = 0;
383
384   foreach my $table ( keys %$data ) {
385
386     my $class = "FS::$table";
387     eval "use $class;";
388     die $@ if $@;
389
390     if ( $class->can('_upgrade_schema') ) {
391       warn "Upgrading $table schema...\n";
392
393       my $start = time;
394
395       $class->_upgrade_schema(%opt);
396
397       if ( $oldAutoCommit ) {
398         warn "  committing\n";
399         dbh->commit or die dbh->errstr;
400       }
401       
402       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
403       warn "  done in ". (time-$start). " seconds\n";
404
405     } else {
406       warn "WARNING: asked for schema upgrade of $table,".
407            " but FS::$table has no _upgrade_schema method\n";
408     }
409
410   }
411
412 }
413
414 =item upgrade_schema_data
415
416 =cut
417
418 sub upgrade_schema_data {
419   my %opt = @_;
420
421   tie my %hash, 'Tie::IxHash', 
422
423     #fix classnum character(1)
424     'cust_bill_pkg_detail' => [],
425     #add necessary columns to RT schema
426     'TicketSystem' => [],
427
428   ;
429
430   \%hash;
431
432 }
433
434 sub upgrade_sqlradius {
435   #my %opt = @_;
436
437   my $conf = new FS::Conf;
438
439   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
440
441   foreach my $part_export ( @part_export ) {
442
443     my $errmsg = 'Error adding FreesideStatus to '.
444                  $part_export->option('datasrc'). ': ';
445
446     my $dbh = DBI->connect(
447       ( map $part_export->option($_), qw ( datasrc username password ) ),
448       { PrintError => 0, PrintWarn => 0 }
449     ) or do {
450       warn $errmsg.$DBI::errstr;
451       next;
452     };
453
454     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
455     my $group = "UserName";
456     $group .= ",Realm"
457       if ref($part_export) =~ /withdomain/
458       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
459
460     my $sth_alter = $dbh->prepare(
461       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
462     );
463     if ( $sth_alter ) {
464       if ( $sth_alter->execute ) {
465         my $sth_update = $dbh->prepare(
466          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
467         ) or die $errmsg.$dbh->errstr;
468         $sth_update->execute or die $errmsg.$sth_update->errstr;
469       } else {
470         my $error = $sth_alter->errstr;
471         warn $errmsg.$error
472           unless $error =~ /Duplicate column name/i  #mysql
473               || $error =~ /already exists/i;        #Pg
474 ;
475       }
476     } else {
477       my $error = $dbh->errstr;
478       warn $errmsg.$error; #unless $error =~ /exists/i;
479     }
480
481     my $sth_index = $dbh->prepare(
482       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
483     );
484     if ( $sth_index ) {
485       unless ( $sth_index->execute ) {
486         my $error = $sth_index->errstr;
487         warn $errmsg.$error
488           unless $error =~ /Duplicate key name/i #mysql
489               || $error =~ /already exists/i;    #Pg
490       }
491     } else {
492       my $error = $dbh->errstr;
493       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
494     }
495
496     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
497       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
498       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
499
500     my $sth = $dbh->prepare("SELECT UserName,
501                                     Realm,
502                                     $str2time max(AcctStartTime)),
503                                     $str2time max(AcctStopTime))
504                               FROM radacct
505                               WHERE FreesideStatus = 'done'
506                                 AND $times
507                               GROUP BY $group
508                             ")
509       or die $errmsg.$dbh->errstr;
510     $sth->execute() or die $errmsg.$sth->errstr;
511   
512     while (my $row = $sth->fetchrow_arrayref ) {
513       my ($username, $realm, $start, $stop) = @$row;
514   
515       $username = lc($username) unless $conf->exists('username-uppercase');
516
517       my $exportnum = $part_export->exportnum;
518       my $extra_sql = " AND exportnum = $exportnum ".
519                       " AND exportsvcnum IS NOT NULL ";
520
521       if ( ref($part_export) =~ /withdomain/ ) {
522         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
523                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
524       }
525   
526       my $svc_acct = qsearchs({
527         'select'    => 'svc_acct.*',
528         'table'     => 'svc_acct',
529         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
530                        'LEFT JOIN export_svc USING ( svcpart )',
531         'hashref'   => { 'username' => $username },
532         'extra_sql' => $extra_sql,
533       });
534
535       if ($svc_acct) {
536         $svc_acct->last_login($start)
537           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
538         $svc_acct->last_logout($stop)
539           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
540       }
541     }
542   }
543
544 }
545
546 =back
547
548 =head1 BUGS
549
550 Sure.
551
552 =head1 SEE ALSO
553
554 =cut
555
556 1;
557