Merge branch 'patch-8' of https://github.com/gjones2/Freeside (#13854 as this bug...
[freeside.git] / FS / FS / cust_main / Import.pm
1 package FS::cust_main::Import;
2
3 use strict;
4 use vars qw( $DEBUG $conf );
5 use Storable qw(thaw);
6 use Data::Dumper;
7 use MIME::Base64;
8 use File::Slurp qw( slurp );
9 use FS::Misc::DateTime qw( parse_datetime );
10 use FS::UID qw( dbh );
11 use FS::Record qw( qsearchs );
12 use FS::cust_main;
13 use FS::svc_acct;
14 use FS::svc_external;
15 use FS::svc_phone;
16 use FS::svc_hardware;
17 use FS::part_referral;
18
19 $DEBUG = 0;
20
21 install_callback FS::UID sub {
22   $conf = new FS::Conf;
23 };
24
25 my %is_location = map { $_ => 1 } FS::cust_main::Location->location_fields;
26
27 =head1 NAME
28
29 FS::cust_main::Import - Batch customer importing
30
31 =head1 SYNOPSIS
32
33   use FS::cust_main::Import;
34
35   #import
36   FS::cust_main::Import::batch_import( {
37     file      => $file,      #filename
38     type      => $type,      #csv or xls
39     format    => $format,    #extended, extended-plus_company, svc_external,
40                              #extended-plus_company_and_options
41                              #extended-plus_options, or svc_external_svc_phone
42     agentnum  => $agentnum,
43     refnum    => $refnum,
44     pkgpart   => $pkgpart,
45     job       => $job,       #optional job queue job, for progressbar updates
46     custbatch => $custbatch, #optional batch unique identifier
47   } );
48   die $error if $error;
49
50   #ajax helper
51   use FS::UI::Web::JSRPC;
52   my $server =
53     new FS::UI::Web::JSRPC 'FS::cust_main::Import::process_batch_import', $cgi;
54   print $server->process;
55
56 =head1 DESCRIPTION
57
58 Batch customer importing.
59
60 =head1 SUBROUTINES
61
62 =item process_batch_import
63
64 Load a batch import as a queued JSRPC job
65
66 =cut
67
68 sub process_batch_import {
69   my $job = shift;
70
71   my $param = thaw(decode_base64(shift));
72   warn Dumper($param) if $DEBUG;
73   
74   my $files = $param->{'uploaded_files'}
75     or die "No files provided.\n";
76
77   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
78
79   my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
80   my $file = $dir. $files{'file'};
81
82   my $type;
83   if ( $file =~ /\.(\w+)$/i ) {
84     $type = lc($1);
85   } else {
86     #or error out???
87     warn "can't parse file type from filename $file; defaulting to CSV";
88     $type = 'csv';
89   }
90
91   my $error =
92     FS::cust_main::Import::batch_import( {
93       job       => $job,
94       file      => $file,
95       type      => $type,
96       custbatch => $param->{custbatch},
97       agentnum  => $param->{'agentnum'},
98       refnum    => $param->{'refnum'},
99       pkgpart   => $param->{'pkgpart'},
100       #'fields'  => [qw( cust_pkg.setup dayphone first last address1 address2
101       #                 city state zip comments                          )],
102       'format'  => $param->{'format'},
103     } );
104
105   unlink $file;
106
107   die "$error\n" if $error;
108
109 }
110
111 =item batch_import
112
113 =cut
114
115
116 #some false laziness w/cdr.pm now
117 sub batch_import {
118   my $param = shift;
119
120   my $job       = $param->{job};
121
122   my $filename  = $param->{file};
123   my $type      = $param->{type} || 'csv';
124
125   my $custbatch = $param->{custbatch};
126
127   my $agentnum  = $param->{agentnum};
128   my $refnum    = $param->{refnum};
129   my $pkgpart   = $param->{pkgpart};
130
131   my $format    = $param->{'format'};
132
133   my @fields;
134   my $payby;
135   if ( $format eq 'simple' ) {
136     @fields = qw( cust_pkg.setup dayphone first last
137                   address1 address2 city state zip comments );
138     $payby = 'BILL';
139   } elsif ( $format eq 'extended' ) {
140     @fields = qw( agent_custid refnum
141                   last first address1 address2 city state zip country
142                   daytime night
143                   ship_last ship_first ship_address1 ship_address2
144                   ship_city ship_state ship_zip ship_country
145                   payinfo paycvv paydate
146                   invoicing_list
147                   cust_pkg.pkgpart
148                   svc_acct.username svc_acct._password 
149                 );
150     $payby = 'BILL';
151  } elsif ( $format eq 'extended-plus_options' ) {
152     @fields = qw( agent_custid refnum
153                   last first address1 address2 city state zip country
154                   daytime night
155                   ship_last ship_first ship_company ship_address1 ship_address2
156                   ship_city ship_state ship_zip ship_country
157                   payinfo paycvv paydate
158                   invoicing_list
159                   cust_pkg.pkgpart
160                   svc_acct.username svc_acct._password 
161                   customer_options
162                 );
163     $payby = 'BILL';
164  } elsif ( $format eq 'extended-plus_company' ) {
165     @fields = qw( agent_custid refnum
166                   last first company address1 address2 city state zip country
167                   daytime night
168                   ship_last ship_first ship_company ship_address1 ship_address2
169                   ship_city ship_state ship_zip ship_country
170                   payinfo paycvv paydate
171                   invoicing_list
172                   cust_pkg.pkgpart
173                   svc_acct.username svc_acct._password 
174                 );
175     $payby = 'BILL';
176  } elsif ( $format eq 'extended-plus_company_and_options' ) {
177     @fields = qw( agent_custid refnum
178                   last first company address1 address2 city state zip country
179                   daytime night
180                   ship_last ship_first ship_company ship_address1 ship_address2
181                   ship_city ship_state ship_zip ship_country
182                   payinfo paycvv paydate
183                   invoicing_list
184                   cust_pkg.pkgpart
185                   svc_acct.username svc_acct._password 
186                   customer_options
187                 );
188     $payby = 'BILL';
189  } elsif ( $format =~ /^svc_external/ ) {
190     @fields = qw( agent_custid refnum
191                   last first company address1 address2 city state zip country
192                   daytime night
193                   ship_last ship_first ship_company ship_address1 ship_address2
194                   ship_city ship_state ship_zip ship_country
195                   payinfo paycvv paydate
196                   invoicing_list
197                   cust_pkg.pkgpart cust_pkg.bill
198                   svc_external.id svc_external.title
199                 );
200     push @fields, map "svc_phone.$_", qw( countrycode phonenum sip_password pin)
201       if $format eq 'svc_external_svc_phone';
202     $payby = 'BILL';
203   } elsif ( $format eq 'birthdates-acct_phone_hardware') {
204     @fields = qw( agent_custid refnum
205                   last first company address1 address2 city state zip country
206                   daytime night
207                   ship_last ship_first ship_company ship_address1 ship_address2
208                   ship_city ship_state ship_zip ship_country
209                   birthdate spouse_birthdate
210                   payinfo paycvv paydate
211                   invoicing_list
212                   cust_pkg.pkgpart cust_pkg.bill
213                   svc_acct.username svc_acct._password 
214                 );
215     push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
216     push @fields, map "svc_hardware.$_", qw(typenum ip_addr hw_addr serial);
217
218     $payby = 'BILL';
219   } elsif ( $format eq 'national_id-acct_phone') {
220     @fields = qw( agent_custid refnum
221                   last first company address1 address2 city state zip country
222                   daytime night
223                   ship_last ship_first ship_company ship_address1 ship_address2
224                   ship_city ship_state ship_zip ship_country
225                   national_id
226                   payinfo paycvv paydate
227                   invoicing_list
228                   cust_pkg.pkgpart cust_pkg.bill
229                   svc_acct.username svc_acct._password svc_acct.slipip
230                 );
231     push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
232
233     $payby = 'BILL';
234   } else {
235     die "unknown format $format";
236   }
237
238   my $count;
239   my $parser;
240   my @buffer = ();
241   if ( $type eq 'csv' ) {
242
243     eval "use Text::CSV_XS;";
244     die $@ if $@;
245
246     $parser = new Text::CSV_XS;
247
248     @buffer = split(/\r?\n/, slurp($filename) );
249     $count = scalar(@buffer);
250
251   } elsif ( $type eq 'xls' ) {
252
253     eval "use Spreadsheet::ParseExcel;";
254     die $@ if $@;
255
256     my $excel = Spreadsheet::ParseExcel::Workbook->new->Parse($filename);
257     $parser = $excel->{Worksheet}[0]; #first sheet
258
259     $count = $parser->{MaxRow} || $parser->{MinRow};
260     $count++;
261
262   } else {
263     die "Unknown file type $type\n";
264   }
265
266   #my $columns;
267
268   local $SIG{HUP} = 'IGNORE';
269   local $SIG{INT} = 'IGNORE';
270   local $SIG{QUIT} = 'IGNORE';
271   local $SIG{TERM} = 'IGNORE';
272   local $SIG{TSTP} = 'IGNORE';
273   local $SIG{PIPE} = 'IGNORE';
274
275   my $oldAutoCommit = $FS::UID::AutoCommit;
276   local $FS::UID::AutoCommit = 0;
277   my $dbh = dbh;
278
279   #implies ignore_expired_card
280   local($FS::cust_main::import) = 1;
281   local($FS::cust_main::import) = 1;
282   
283   my $line;
284   my $row = 0;
285   my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
286   while (1) {
287
288     my @columns = ();
289     if ( $type eq 'csv' ) {
290
291       last unless scalar(@buffer);
292       $line = shift(@buffer);
293
294       $parser->parse($line) or do {
295         $dbh->rollback if $oldAutoCommit;
296         return "can't parse: ". $parser->error_input();
297       };
298       @columns = $parser->fields();
299
300     } elsif ( $type eq 'xls' ) {
301
302       last if $row > ($parser->{MaxRow} || $parser->{MinRow})
303            || ! $parser->{Cells}[$row];
304
305       my @row = @{ $parser->{Cells}[$row] };
306       @columns = map $_->{Val}, @row;
307
308       #my $z = 'A';
309       #warn $z++. ": $_\n" for @columns;
310
311     } else {
312       die "Unknown file type $type\n";
313     }
314
315     #warn join('-',@columns);
316
317     my %cust_main = (
318       custbatch => $custbatch,
319       agentnum  => $agentnum,
320       refnum    => $refnum,
321       payby     => $payby, #default
322       paydate   => '12/2037', #default
323     );
324     my $billtime = time;
325     my %cust_pkg = ( pkgpart => $pkgpart );
326     my %svc_x = ();
327     my %bill_location = ();
328     my %ship_location = ();
329     foreach my $field ( @fields ) {
330
331       if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
332
333         #$cust_pkg{$1} = parse_datetime( shift @$columns );
334         if ( $1 eq 'pkgpart' ) {
335           $cust_pkg{$1} = shift @columns;
336         } elsif ( $1 eq 'setup' ) {
337           $billtime = parse_datetime(shift @columns);
338         } else {
339           $cust_pkg{$1} = parse_datetime( shift @columns );
340         } 
341
342       } elsif ( $field =~ /^svc_acct\.(username|_password|slipip)$/ ) {
343
344         $svc_x{$1} = shift @columns;
345
346       } elsif ( $field =~ /^svc_external\.(id|title)$/ ) {
347
348         $svc_x{$1} = shift @columns;
349
350       } elsif ( $field =~ /^svc_phone\.(countrycode|phonenum|sip_password|pin)$/ ) {
351         $svc_x{$1} = shift @columns;
352       
353       } elsif ( $field =~ /^svc_hardware\.(typenum|ip_addr|hw_addr|serial)$/ ) {
354
355         $svc_x{$1} = shift @columns;
356
357       } elsif ( $is_location{$field} ) {
358
359         $bill_location{$field} = shift @columns;
360
361       } elsif ( $field =~ /^ship_(.*)$/ and $is_location{$1} ) {
362
363         $ship_location{$1} = shift @columns;
364       
365       } else {
366
367         #refnum interception
368         if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
369
370           my $referral = $columns[0];
371           my %hash = ( 'referral' => $referral,
372                        'agentnum' => $agentnum,
373                        'disabled' => '',
374                      );
375
376           my $part_referral = qsearchs('part_referral', \%hash )
377                               || new FS::part_referral \%hash;
378
379           unless ( $part_referral->refnum ) {
380             my $error = $part_referral->insert;
381             if ( $error ) {
382               $dbh->rollback if $oldAutoCommit;
383               return "can't auto-insert advertising source: $referral: $error";
384             }
385           }
386
387           $columns[0] = $part_referral->refnum;
388         }
389
390         my $value = shift @columns;
391         $cust_main{$field} = $value if length($value);
392       }
393     } # foreach my $field
394     # finished importing columns
395
396     $bill_location{'country'} ||= $conf->config('countrydefault') || 'US';
397     $cust_main{'bill_location'} = FS::cust_location->new(\%bill_location);
398     if ( grep $_, values(%ship_location) ) {
399       $ship_location{'country'} ||= $conf->config('countrydefault') || 'US';
400       $cust_main{'ship_location'} = FS::cust_location->new(\%ship_location);
401     } else {
402       $cust_main{'ship_location'} = $cust_main{'bill_location'};
403     }
404
405     if ( defined $cust_main{'payinfo'} && length $cust_main{'payinfo'} ) {
406       $cust_main{'payby'} = 'CARD';
407       if ($cust_main{'payinfo'} =~ /\s*([AD]?)(.*)\s*$/) {
408         $cust_main{'payby'} = 'DCRD' if $1 eq 'D';
409         $cust_main{'payinfo'} = $2;
410       }
411     }
412
413     $cust_main{$_} = parse_datetime($cust_main{$_})
414       foreach grep $cust_main{$_},
415         qw( birthdate spouse_birthdate anniversary_date );
416
417     my $invoicing_list = $cust_main{'invoicing_list'}
418                            ? [ delete $cust_main{'invoicing_list'} ]
419                            : [];
420
421     my $customer_options = delete $cust_main{customer_options};
422     $cust_main{tax} = 'Y' if $customer_options =~ /taxexempt/i;
423     push @$invoicing_list, 'POST' if $customer_options =~ /postalinvoice/i;
424
425     my $cust_main = new FS::cust_main ( \%cust_main );
426
427     use Tie::RefHash;
428     tie my %hash, 'Tie::RefHash'; #this part is important
429
430     if ( $cust_pkg{'pkgpart'} ) {
431
432       unless ( $cust_pkg{'pkgpart'} =~ /^\d+$/ ) {
433         $dbh->rollback if $oldAutoCommit;
434         return 'illegal pkgpart: '. $cust_pkg{'pkgpart'};
435       }
436
437       my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
438
439       my @svc_x = ();
440       my $svcdb = '';
441       if ( $svc_x{'username'} ) {
442         $svcdb = 'svc_acct';
443       } elsif ( $svc_x{'id'} || $svc_x{'title'} ) {
444         $svcdb = 'svc_external';
445       }
446
447       my $svc_phone = '';
448       if ( $svc_x{'countrycode'} || $svc_x{'phonenum'} ) {
449         $svc_phone = FS::svc_phone->new( {
450           map { $_ => delete($svc_x{$_}) }
451               qw( countrycode phonenum sip_password pin )
452         } );
453       }
454
455       my $svc_hardware = '';
456       if ( $svc_x{'typenum'} ) {
457         $svc_hardware = FS::svc_hardware->new( {
458           map { $_ => delete($svc_x{$_}) }
459             qw( typenum ip_addr hw_addr serial )
460         } );
461       }
462
463       if ( $svcdb || $svc_phone || $svc_hardware ) {
464         my $part_pkg = $cust_pkg->part_pkg;
465         unless ( $part_pkg ) {
466           $dbh->rollback if $oldAutoCommit;
467           return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
468         } 
469         if ( $svcdb ) {
470           $svc_x{svcpart} = $part_pkg->svcpart_unique_svcdb( $svcdb );
471           my $class = "FS::$svcdb";
472           push @svc_x, $class->new( \%svc_x );
473         }
474         if ( $svc_phone ) {
475           $svc_phone->svcpart( $part_pkg->svcpart_unique_svcdb('svc_phone') );
476           push @svc_x, $svc_phone;
477         }
478         if ( $svc_hardware ) {
479           $svc_hardware->svcpart( $part_pkg->svcpart_unique_svcdb('svc_hardware') );
480           push @svc_x, $svc_hardware;
481         }
482
483       }
484
485       $hash{$cust_pkg} = \@svc_x;
486     }
487
488     my $error = $cust_main->insert( \%hash, $invoicing_list );
489
490     if ( $error ) {
491       $dbh->rollback if $oldAutoCommit;
492       return "can't insert customer". ( $line ? " for $line" : '' ). ": $error";
493     }
494
495     if ( $format eq 'simple' ) {
496
497       #false laziness w/bill.cgi
498       $error = $cust_main->bill( 'time' => $billtime );
499       if ( $error ) {
500         $dbh->rollback if $oldAutoCommit;
501         return "can't bill customer for $line: $error";
502       }
503   
504       $error = $cust_main->apply_payments_and_credits;
505       if ( $error ) {
506         $dbh->rollback if $oldAutoCommit;
507         return "can't bill customer for $line: $error";
508       }
509
510       $error = $cust_main->collect();
511       if ( $error ) {
512         $dbh->rollback if $oldAutoCommit;
513         return "can't collect customer for $line: $error";
514       }
515
516     }
517
518     $row++;
519
520     if ( $job && time - $min_sec > $last ) { #progress bar
521       $job->update_statustext( int(100 * $row / $count) );
522       $last = time;
523     }
524
525   }
526
527   $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
528
529   return "Empty file!" unless $row;
530
531   ''; #no error
532
533 }
534
535 =head1 BUGS
536
537 Not enough documentation.
538
539 =head1 SEE ALSO
540
541 L<FS::cust_main>, L<FS::cust_pkg>,
542 L<FS::svc_acct>, L<FS::svc_external>, L<FS::svc_phone>
543
544 =cut
545
546 1;