Merge branch 'issue/SCT-1140' of https://github.com/Jayceh/Freeside
authorIvan Kohler <ivan@freeside.biz>
Sat, 17 Jan 2015 02:53:25 +0000 (18:53 -0800)
committerIvan Kohler <ivan@freeside.biz>
Sat, 17 Jan 2015 02:53:25 +0000 (18:53 -0800)
15 files changed:
FS/FS/Misc/Getopt.pm [new file with mode: 0644]
FS/FS/Record.pm
FS/FS/class_Common.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/part_export/a2billing.pm
FS/FS/part_export/voip_ms.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/svc_acct.pm
FS/MANIFEST
bin/cdr-voip_ms.import [new file with mode: 0755]
debian/freeside.docs
httemplate/elements/mac_addr.html [new file with mode: 0644]
httemplate/search/elements/cust_pay_or_refund.html
httemplate/view/cust_main/packages/package.html
httemplate/view/elements/svc_Common.html

diff --git a/FS/FS/Misc/Getopt.pm b/FS/FS/Misc/Getopt.pm
new file mode 100644 (file)
index 0000000..a9d0ecf
--- /dev/null
@@ -0,0 +1,112 @@
+package FS::Misc::Getopt;
+
+=head1 NAME
+
+FS::Misc::Getopt - Getopt::Std for Freeside command line/cron scripts
+
+=head1 SYNOPSIS
+
+#!/usr/bin/perl
+
+use FS::Getopt;
+use FS::other_stuff;
+our %opt;
+
+getopts('AB');
+
+print "Option A: $opt{A}
+Option B: $opt{B}
+Start date: $opt{start}
+End date: $opt{end}
+Freeside user: $opt{user}
+Verbose mode: $DEBUG
+";
+
+=head1 DESCRIPTION
+
+This module provides a wrapper around Getopt::Std::getopts() that 
+automatically processes certain common command line options, and sets
+up a convenient environment for writing a script.
+
+Options will go into %main::opt, as if you had called getopts(..., \%opt).
+All options recognized by the wrapper use (and will always use) lowercase 
+letters as flags, so it's safe for a script to define its options as
+capital letters.
+
+Options recognized by the wrapper do not need to be included in the string
+argument to getopts().
+
+The following command line options are recognized:
+
+=over 4
+
+=item -v: Verbose mode. Sets $main::DEBUG.
+
+=item -s: Start date. If provided, FS::Getopt will parse it as a date 
+and set $opt{start} to the resulting Unix timestamp value. If parsing fails, 
+displays an error and exits.
+
+=item -e: End date. As for -s; sets $opt{end}.
+
+=back
+
+Calling getopts() also performs some additional setup: 
+
+=over 4
+
+=item Exports a function named &main::debug, which performs a warn() if 
+$DEBUG has a true value, and if not, does nothing. This should be used to
+output informational messages. (warn() is for warnings.)
+
+=item Captures the first command line argument after any switches and 
+sets $opt{user} to that value. If a value isn't provided, prints an error
+and exits.
+
+=item Loads L<FS::UID> and calls adminsuidsetup() to connect to the database.
+
+=back
+
+=cut
+
+use strict;
+use base 'Exporter';
+use Getopt::Std ();
+use FS::UID qw(adminsuidsetup);
+use FS::Misc::DateTime qw(parse_datetime day_end);
+
+our @EXPORT = qw( getopts debug );
+
+sub getopts {
+  my $optstring = shift;
+  my %opt;
+  $optstring .= 's:e:v';
+
+  Getopt::Std::getopts($optstring, \%opt);
+
+  $opt{user} = shift(@ARGV)
+    or die "Freeside username required.\n";
+  adminsuidsetup($opt{user})
+    or die "Failed to connect as user '$opt{user}'.\n";
+
+  # now we have config access
+  if ( $opt{s} ) {
+    $opt{start} = parse_datetime($opt{s})
+      or die "Unable to parse start date '$opt{s}'.\n";
+  }
+  if ( $opt{e} ) {
+    $opt{end} = parse_datetime($opt{e})
+      or die "Unable to parse start date '$opt{e}'.\n";
+    $opt{end} = day_end($opt{end});
+  }
+  if ( $opt{v} ) {
+    $main::DEBUG ||= $opt{v};
+  }
+
+  %main::opt = %opt;
+}
+
+sub debug {
+  warn(@_, "\n") if $main::DEBUG;
+}
+
+1;
index 781230f..25e61d0 100644 (file)
@@ -3336,6 +3336,22 @@ sub count {
   $self->scalar_sql($sql, @_);
 }
 
+=item row_exists [ WHERE [, PLACEHOLDER ...] ]
+
+Convenience method for the common case of "SELECT 1 FROM table ... LIMIT 1"
+with optional (but almost always needed) WHERE.
+
+=cut
+
+sub row_exists {
+  my($self, $where) = (shift, shift);
+  my $table = $self->table or die 'row_exists called on object of class '.ref($self);
+  my $sql = "SELECT 1 FROM $table";
+  $sql .= " WHERE $where" if $where;
+  $sql .= " LIMIT 1";
+  $self->scalar_sql($sql, @_);
+}
+
 =back
 
 =head1 SUBROUTINES
index 455cb9f..01048ec 100644 (file)
@@ -122,14 +122,15 @@ sub _target_table {
 
 sub _target_column { 'classnum'; }
 
-use vars qw( $_category_table );
+use vars qw( %_category_table );
 sub _category_table {
-  return $_category_table if $_category_table;
   my $self = shift;
-  $_category_table = $self->table;
-  $_category_table =~ s/class/category/ # s/_class$/_category/
-    or die "can't determine an automatic category table for $_category_table";
-  $_category_table;
+  return $_category_table{ ref $self } ||= do {
+    my $category_table = $self->table;
+    $category_table =~ s/class/category/ # s/_class$/_category/
+      or die "can't determine an automatic category table for $category_table";
+    $category_table;
+  }
 }
 
 =head1 BUGS
index 8285cbf..330a454 100644 (file)
@@ -3,6 +3,7 @@ package FS::cust_main::Billing_Realtime;
 use strict;
 use vars qw( $conf $DEBUG $me );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
+use Carp;
 use Data::Dumper;
 use Business::CreditCard 0.28;
 use FS::UID qw( dbh );
@@ -319,6 +320,10 @@ my %bop_method2payby = (
 sub realtime_bop {
   my $self = shift;
 
+  confess "Can't call realtime_bop within another transaction ".
+          '($FS::UID::AutoCommit is false)'
+    unless $FS::UID::AutoCommit;
+
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
  
   my %options = ();
@@ -360,6 +365,8 @@ sub realtime_bop {
   if ( $DEBUG ) {
     warn "$me realtime_bop (new): $options{method} $options{amount}\n";
     warn " cc_surcharge = $cc_surcharge\n";
+  }
+  if ( $DEBUG > 2 ) {
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
@@ -542,7 +549,9 @@ sub realtime_bop {
                   ? $options{'balance'}
                   : $self->balance;
 
+  warn "claiming mutex on customer ". $self->custnum. "\n" if $DEBUG > 1;
   $self->select_for_update; #mutex ... just until we get our pending record in
+  warn "obtained mutex on customer ". $self->custnum. "\n" if $DEBUG > 1;
 
   #the checks here are intended to catch concurrent payments
   #double-form-submission prevention is taken care of in cust_pay_pending::check
@@ -593,9 +602,16 @@ sub realtime_bop {
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
+
+  warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n"
+    if $DEBUG > 1;
   my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
   return $cpp_new_err if $cpp_new_err;
 
+  warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n"
+    if $DEBUG > 1;
+  warn Dumper($cust_pay_pending) if $DEBUG > 2;
+
   my( $action1, $action2 ) =
     split( /\s*\,\s*/, $payment_gateway->gateway_action );
 
index 7aab01a..b080d07 100644 (file)
@@ -144,6 +144,9 @@ sub export_insert {
       zipcode   => $location->zip,
       simultaccess  => $part_pkg->option('a2billing_simultaccess'),
       typepaid  => $part_pkg->option('a2billing_type'),
+      email_notification => $cust_main->invoicing_list_emailonly_scalar,
+      notify_email => ($cust_main->invoicing_list_emailonly_scalar ? 1 : 0),
+      credit_notification => $cust_main->credit_limit || $self->option('credit') || 0,
       sip_buddy => 1,
       company_name => $cust_main->company,
       activated => 't',
@@ -233,12 +236,14 @@ sub export_insert {
 
     my $cc_did_id = $self->a2b_find('cc_did', 'svcnum', $svc->svcnum);
     
-    my $destination = 'SIP/' . $svc->phonenum . '@' . $svc_acct->username;
+    my $destination = 'SIP/user-'. $svc_acct->username. '@'. $svc->sip_server. "!". $svc->phonenum;
     my %cc_did_destination = (
       destination     => $destination,
       priority        => 1,
       id_cc_card      => $cc_card_id,
       id_cc_did       => $cc_did_id,
+      validated       => 1,
+      voip_call       => 1,
     );
 
     # and if there's already a destination, change it to point to
index 53a4926..7766eac 100644 (file)
@@ -10,9 +10,14 @@ use URI::Escape;
 use JSON;
 use HTTP::Request::Common;
 use Cache::FileCache;
+use FS::Record qw(dbh);
+use FS::Misc::DateTime qw(parse_datetime);
+use DateTime;
 
 our $me = '[voip.ms]';
-our $DEBUG = 2;
+our $DEBUG = 0;
+# our $DEBUG = 1; # log requests
+# our $DEBUG = 2; # log requests and content of replies
 our $base_url = 'https://voip.ms/api/v1/rest.php';
 
 # cache cities and provinces
@@ -222,6 +227,9 @@ sub export_unsuspend {
   '';
 }
 
+################
+# PROVISIONING #
+################
 
 sub insert_subacct {
   my ($self, $svc_acct) = @_;
@@ -587,6 +595,142 @@ sub reload_cache {
   }
 }
 
+################
+# CALL DETAILS #
+################
+
+=item import_cdrs START, END
+
+Retrieves CDRs for calls in the date range from START to END and inserts them
+as a new CDR batch. On success, returns a new cdr_batch object. On failure,
+returns an error message. If there are no new CDRs, returns nothing.
+
+=cut
+
+sub import_cdrs {
+  my ($self, $start, $end) = @_;
+  $start ||= 0; # all CDRs ever
+  $end ||= time;
+  $DEBUG ||= $self->option('debug');
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  ($start, $end) = ($end, $start) if $end < $start;
+  $start = DateTime->from_epoch(epoch => $start, time_zone => 'local');
+  $end = DateTime->from_epoch(epoch => $end, time_zone => 'local');
+  my $accountnum = $self->option('account');
+  my $cdr_batch;
+  # can't retrieve more than 92 days at a time
+  # actually, it's even less than that; on large batches their server
+  # sometimes cuts off in mid-sentence. so set the chunk size smaller.
+  while ( $start < $end ) {
+
+    my $this_end = $start->clone;
+    $this_end->add(days => 14);
+    if ($this_end > $end) {
+      $this_end = $end;
+    }
+
+    my $date_from = $start->strftime('%F');
+    my $date_to = $this_end->strftime('%F');
+    warn "retrieving CDRs from $date_from to $date_to\n" if $DEBUG;
+    my $timezone = $start->strftime('%z') / 100; # integer number of hours
+    my $result = $self->api_request('getCDR', {
+        date_from => $date_from,
+        date_to   => $date_to,
+        answered  => 1,
+        noanswer  => 1,
+        busy      => 1,
+        failed    => 1,
+        timezone  => $timezone,
+    });
+    if ( $result->{status} eq 'success' ) {
+      if (!$cdr_batch) {
+        # then create one
+        my $cdrbatchname = 'voip_ms-' . $self->exportnum . '-' . $end->epoch;
+        $cdr_batch = FS::cdr_batch->new({ cdrbatch => $cdrbatchname });
+        my $error = $cdr_batch->insert;
+        if ( $error ) {
+          dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+      }
+
+      foreach ( @{ $result->{cdr} } ) {
+        my $uniqueid = $_->{uniqueid};
+        # download ranges may overlap; avoid double-importing CDRs
+        if ( FS::cdr->row_exists("uniqueid = ?", $uniqueid) ) {
+          warn "skipped call with uniqueid = '$uniqueid' (already imported)\n"
+            if $DEBUG;
+          next;
+        }
+        # in this case, and probably in other cases in the near future,
+        # easier to do this than to create a FS::cdr::* format module
+        my $hash = {
+          disposition             => $_->{disposition},
+          calldate                => $_->{date},
+          dst                     => $_->{destination},
+          uniqueid                => $_->{uniqueid},
+          upstream_price          => $_->{total},
+          upstream_dst_regionname => $_->{description},
+          clid                    => $_->{callerid},
+          duration                => $_->{seconds},
+          billsec                 => $_->{seconds},
+          cdrbatchnum             => $cdr_batch->cdrbatchnum,
+        };
+        if ( $_->{date} ) {
+          $hash->{startdate} = parse_datetime($_->{date});
+        }
+        if ( $_->{account} eq $accountnum ) {
+          # calls made from the master account, not a subaccount
+          # charged_party will be set to the source number
+          $hash->{charged_party} = '';
+        } elsif ( $_->{account} =~ /^${accountnum}_(\w+)$/ ) {
+          $hash->{charged_party} = $1;
+        } else {
+          warn "skipped call with account = '$_->{account}'\n";
+          next;
+        }
+        if ( $_->{callerid} =~ /<(\w+)>$/ ) {
+          $hash->{src} = $1;
+        } elsif ( $_->{callerid} =~ /^(\w+)$/ ) {
+          $hash->{src} = $1;
+        } else {
+          # else what? they don't have a source number anywhere else
+          warn "skipped call with unparseable callerid '$_->{callerid}'\n";
+          next;
+        }
+
+        my $cdr = FS::cdr->new($hash);
+        my $error = $cdr->insert;
+        if ( $error ) {
+          dbh->rollback if $oldAutoCommit;
+          return "$error (uniqueid $_->{uniqueid})";
+        }
+      } # foreach @{ $result->{cdr} }
+
+    } elsif ( $result->{status} eq 'no_cdr' ) {
+      # normal result if there are no CDRs, duh
+      next; # there may still be more CDRs later
+    } else {
+      dbh->rollback if $oldAutoCommit;
+      return "$me error retrieving CDRs: $result->{status}";
+    }
+
+    # we've retrieved and inserted this sub-batch of CDRs
+    $start->add(days => 15);
+  } # while ( $start < $end )
+
+  if ( $cdr_batch ) {
+    dbh->commit if $oldAutoCommit;
+    return $cdr_batch;
+  } else {
+    # no CDRs were ever found
+    return;
+  }
+}
+
 ##############
 # API ACCESS #
 ##############
@@ -614,15 +758,21 @@ sub api_request {
     'Accept'        => 'text/json',
   );
 
-  warn "$me $method\n" . $request->as_string ."\n" if $DEBUG;
+  warn "$me $method\n" if $DEBUG;
+  warn $request->as_string ."\n" if $DEBUG > 1;
   my $ua = LWP::UserAgent->new;
   my $response = $ua->request($request);
-  warn "$me received\n" . $response->as_string ."\n" if $DEBUG;
+  warn "$me received\n" . $response->as_string ."\n" if $DEBUG > 1;
   if ( !$response->is_success ) {
     return { status => $response->content };
   }
 
-  return decode_json($response->content);
+  local $@;
+  my $decoded_response = eval { decode_json($response->content) };
+  if ( $@ ) {
+    die "Error parsing response:\n" . $response->content . "\n\n";
+  }
+  return $decoded_response;
 }
 
 =item api_insist METHOD, CONTENT
index 8278afe..205335b 100644 (file)
@@ -19,6 +19,7 @@ tie my %cdr_svc_method, 'Tie::IxHash',
   'svc_pbx.svcnum'     => 'Freeside service # (svc_pbx.svcnum)',
   'svc_pbx.ip.src'     => 'PBX name to source IP address',
   'svc_pbx.ip.dst'     => 'PBX name to destination IP address',
+  'svc_acct.username'  => 'Username (svc_acct.username)',
 ;
 
 tie my %rating_method, 'Tie::IxHash',
@@ -463,16 +464,20 @@ sub calc_usage {
     #my @invoice_details_sort;
 
     # for tagging invoice details
+    # (unfortunate; should be a svc_x class method or table_info item or 
+    # something)
     my $phonenum;
     if ( $svc_table eq 'svc_phone' ) {
       $phonenum = $svc_x->phonenum;
     } elsif ( $svc_table eq 'svc_pbx' ) {
       $phonenum = $svc_x->title;
+    } elsif ( $svc_table eq 'svc_acct' ) {
+      $phonenum = $svc_x->username;
     }
     $formatter->phonenum($phonenum);
 
     #first rate any outstanding CDRs not yet rated
-    # XXX eventually use an FS::Cursor for this
+    # use FS::Cursor for this starting in 4.x
     my $cdr_search = $svc_x->psearch_cdrs(%options);
     $cdr_search->limit(1000);
     $cdr_search->increment(0); # because we're changing their status as we go
@@ -678,6 +683,7 @@ sub reset_usage {
 
 # tells whether cust_bill_pkg_detail should return a single line for 
 # each phonenum
+# i think this is currently unused?
 sub sum_usage {
   my $self = shift;
   $self->option('output_format') =~ /^sum_/;
index 62cb633..452f250 100644 (file)
@@ -40,6 +40,7 @@ use FS::Record qw( qsearch qsearchs fields dbh dbdef );
 use FS::Msgcat qw(gettext);
 use FS::UI::bytecount;
 use FS::UI::Web;
+use FS::PagedSearch qw( psearch ); # XXX in v4, replace with FS::Cursor
 use FS::part_pkg;
 use FS::part_svc;
 use FS::svc_acct_pop;
@@ -293,25 +294,21 @@ sub table_info {
                          label => 'Quota', #Mail storage limit
                          type => 'text',
                          disable_inventory => 1,
-                         disable_select => 1,
                        },
         'file_quota'=> { 
                          label => 'File storage limit',
                          type => 'text',
                          disable_inventory => 1,
-                         disable_select => 1,
                        },
         'file_maxnum'=> { 
                          label => 'Number of files limit',
                          type => 'text',
                          disable_inventory => 1,
-                         disable_select => 1,
                        },
         'file_maxsize'=> { 
                          label => 'File size limit',
                          type => 'text',
                          disable_inventory => 1,
-                         disable_select => 1,
                        },
         '_password' => 'Password',
         'gid'       => {
@@ -2371,65 +2368,94 @@ sub last_login_text {
   $self->last_login ? ctime($self->last_login) : 'unknown';
 }
 
-=item get_cdrs TIMESTAMP_START TIMESTAMP_END [ 'OPTION' => 'VALUE ... ]
+=item psearch_cdrs OPTIONS
+
+Returns a paged search (L<FS::PagedSearch>) for Call Detail Records
+associated with this service. For svc_acct, "associated with" means that
+either the "src" or the "charged_party" field of the CDR matches the
+"username" field of the service.
 
 =cut
 
-sub get_cdrs {
-  my($self, $start, $end, %opt ) = @_;
-
-  my $did = $self->username; #yup
-
-  my $prefix = $opt{'default_prefix'}; #convergent.au '+61'
-
-  my $for_update = $opt{'for_update'} ? 'FOR UPDATE' : '';
-
-  #SELECT $for_update * FROM cdr
-  #  WHERE calldate >= $start #need a conversion
-  #    AND calldate <  $end   #ditto
-  #    AND (    charged_party = "$did"
-  #          OR charged_party = "$prefix$did" #if length($prefix);
-  #          OR ( ( charged_party IS NULL OR charged_party = '' )
-  #               AND
-  #               ( src = "$did" OR src = "$prefix$did" ) # if length($prefix)
-  #             )
-  #        )
-  #    AND ( freesidestatus IS NULL OR freesidestatus = '' )
-
-  my $charged_or_src;
-  if ( length($prefix) ) {
-    $charged_or_src =
-      " AND (    charged_party = '$did' 
-              OR charged_party = '$prefix$did'
-              OR ( ( charged_party IS NULL OR charged_party = '' )
-                   AND
-                   ( src = '$did' OR src = '$prefix$did' )
-                 )
-            )
-      ";
-  } else {
-    $charged_or_src = 
-      " AND (    charged_party = '$did' 
-              OR ( ( charged_party IS NULL OR charged_party = '' )
-                   AND
-                   src = '$did'
-                 )
-            )
-      ";
+sub psearch_cdrs {
+  my($self, %options) = @_;
+  my @fields;
+  my %hash;
+  my @where;
 
+  my $did = dbh->quote($self->username);
+
+  my $prefix = $options{'default_prefix'} || ''; #convergent.au '+61'
+  my $prefixdid = dbh->quote($prefix . $self->username);
+
+  my $for_update = $options{'for_update'} ? 'FOR UPDATE' : '';
+
+  if ( $options{inbound} ) {
+    # these will be selected under their DIDs
+    push @where, "FALSE";
   }
 
-  qsearch(
-    'select'    => "$for_update *",
+  my @orwhere;
+  if (!$options{'disable_charged_party'}) {
+    push @orwhere,
+      "charged_party = $did",
+      "charged_party = $prefixdid";
+  }
+  if (!$options{'disable_src'}) {
+    push @orwhere,
+      "src = $did AND charged_party IS NULL",
+      "src = $prefixdid AND charged_party IS NULL";
+  }
+  push @where, '(' . join(' OR ', @orwhere) . ')';
+
+  # $options{'status'} = '' is meaningful; for the rest of them it's not
+  if ( exists $options{'status'} ) {
+    $hash{'freesidestatus'} = $options{'status'};
+  }
+  if ( $options{'cdrtypenum'} ) {
+    $hash{'cdrtypenum'} = $options{'cdrtypenum'};
+  }
+  if ( $options{'calltypenum'} ) {
+    $hash{'calltypenum'} = $options{'calltypenum'};
+  }
+  if ( $options{'begin'} ) {
+    push @where, 'startdate >= '. $options{'begin'};
+  } 
+  if ( $options{'end'} ) {
+    push @where, 'startdate < '.  $options{'end'};
+  } 
+  if ( $options{'nonzero'} ) {
+    push @where, 'duration > 0';
+  } 
+
+  my $extra_sql = join(' AND ', @where);
+  if ($extra_sql) {
+    if (keys %hash) {
+      $extra_sql = " AND ".$extra_sql;
+    } else {
+      $extra_sql = " WHERE ".$extra_sql;
+    }
+  }
+  return psearch({
+    'select'    => '*',
     'table'     => 'cdr',
-    'hashref'   => {
-                     #( freesidestatus IS NULL OR freesidestatus = '' )
-                     'freesidestatus' => '',
-                   },
-    'extra_sql' => $charged_or_src,
+    'hashref'   => \%hash,
+    'extra_sql' => $extra_sql,
+    'order_by'  => "ORDER BY startdate $for_update",
+  });
+}
 
-  );
+=item get_cdrs (DEPRECATED)
+
+Like psearch_cdrs, but returns all the L<FS::cdr> objects at once, in a 
+single list. Arguments are the same as for psearch_cdrs.
+
+=cut
 
+sub get_cdrs {
+  my $self = shift;
+  my $psearch = $self->psearch_cdrs(@_);
+  qsearch ( $psearch->{query} )
 }
 
 # sub radius_groups has moved to svc_Radius_Mixin
index 618ad59..581ab0d 100644 (file)
@@ -555,6 +555,7 @@ FS/part_event/Action/pkg_agent_credit_pkg.pm
 FS/part_event/Action/pkg_employee_credit.pm
 FS/part_event/Action/pkg_employee_credit_pkg.pm
 FS/Misc/DateTime.pm
+FS/Misc/Getopt.pm
 FS/cgp_rule.pm
 t/cgp_rule.t
 FS/cgp_rule_condition.pm
diff --git a/bin/cdr-voip_ms.import b/bin/cdr-voip_ms.import
new file mode 100755 (executable)
index 0000000..31fff0b
--- /dev/null
@@ -0,0 +1,70 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::Misc::Getopt;
+use FS::cdr_batch;
+use FS::part_export;
+use FS::Record qw(qsearch qsearchs dbh);
+use Date::Format 'time2str';
+
+###
+# parse command line
+###
+
+our %opt;
+getopts('');
+
+$FS::UID::AutoCommit = 0;
+
+my @exports = qsearch('part_export', { exporttype => 'voip_ms' });
+if (!@exports) {
+  die "There are no voip.ms exports configured.\n";
+}
+
+foreach my $part_export (@exports) {
+  debug "Account #".$part_export->option('account');
+
+  if (!$opt{start}) {
+    # find the most recently downloaded batch
+    my $exportnum = $part_export->exportnum;
+    my $most_recent = qsearchs({
+        'table'     => 'cdr_batch',
+        'hashref'   => { 'cdrbatch' => {op=>'like',
+                                        value=>'voip_ms-' . $exportnum . '-%'}
+                       },
+        'order_by'  => 'ORDER BY _date DESC LIMIT 1',
+    });
+    if ( $most_recent ) {
+      $most_recent->cdrbatch =~ /-(\d+)$/; # extract the end timestamp
+      $opt{start} = $1;
+      debug "Downloading records since most recent batch: ".
+            time2str('%Y-%m-%d', $opt{start});
+    } else {
+      $opt{start} = 1262332800;
+      debug "Downloading records since January 2010.";
+    }
+  }
+
+  $opt{end} ||= time;
+
+  my $error_or_batch = $part_export->import_cdrs( $opt{start}, $opt{end} );
+  if ( ref $error_or_batch ) {
+    debug "Created batch #".$error_or_batch->cdrbatchnum;
+    dbh->commit;
+  } elsif ( $error_or_batch ) {
+    warn $error_or_batch;
+    dbh->rollback;
+  } else {
+    debug "No CDRs found."
+  }
+}
+
+sub usage {
+  "Usage: \n  cdr-voip_ms.import [ options ] user
+  Options:
+    -v: be verbose
+    -s date: start date (defaults to the most recent batch date)
+    -e date: end date
+";
+}
+
index 64bf802..f4a511b 100644 (file)
@@ -1,3 +1,2 @@
 README
 AGPL
-bin/
diff --git a/httemplate/elements/mac_addr.html b/httemplate/elements/mac_addr.html
new file mode 100644 (file)
index 0000000..1d867f5
--- /dev/null
@@ -0,0 +1,53 @@
+% if (!$init) {
+%   if ($clipboard_hack) {
+<& init_overlib.html &>
+<script>
+<&| /elements/onload.js &>
+  var transform_text = function(str) {
+    var regexp = new RegExp('.*(..):(..):(..):(..):(..):(..).*');
+    return  str.replace(regexp, '$1$2$3$4$5$6');
+  }
+  var span_onclick = function() {
+    var input = document.createElement('INPUT');
+    // IE8 doesn't support textContent
+    var str = this.textContent || this.innerText || '';
+    input.value = transform_text(str);
+    input.style.position = 'absolute';
+    input.style.top = '0px';
+    input.style.left = '0px';
+    input.onblur = function() { input.parentNode.removeChild(input) }
+    this.appendChild(input);
+    input.select();
+  }
+  // set this on any ".mac_addr" object in the doc
+
+  // IE8 doesn't support getElementsByClassName
+  var els = document.getElementsByTagName('span');
+  for (var i = 0; i < els.length; i++) {
+    if (els[i].className = 'mac_addr') {
+      els[i].id = 'span_mac_addr' + i;
+      els[i].onclick = span_onclick;
+    }
+  }
+</&>
+</SCRIPT>
+<style type="text/css">
+.mac_addr {
+  border-bottom: 1px dotted blue;
+  color: blue;
+  position: relative;
+}
+</style>
+%   } # if $clipboard_hack
+% $init++;
+% }
+%# the only part to be included in every instance
+<SPAN CLASS="mac_addr"><% $value |h %></SPAN>
+<%shared>
+my $init = 0;
+</%shared>
+<%init>
+my $clipboard_hack =
+  $FS::CurrentUser::CurrentUser->option('enable_mask_clipboard_hack');
+my $value = shift; # no other params
+</%init>
index 8aece0c..b5172fb 100755 (executable)
@@ -239,14 +239,14 @@ if ( $cgi->param('magic') ) {
   if ( $cgi->param('magic') eq '_date' ) {
 
     if ( $cgi->param('agentnum') && $cgi->param('agentnum') =~ /^(\d+)$/ ) {
-      push @search, "agentnum = $1"; # $search{'agentnum'} = $1;
+      push @search, "cust_main.agentnum = $1"; # $search{'agentnum'} = $1;
       my $agent = qsearchs('agent', { 'agentnum' => $1 } );
       die "unknown agentnum $1" unless $agent;
       $title = $agent->agent. " $title";
     }
 
     if ( $cgi->param('refnum') && $cgi->param('refnum') =~ /^(\d+)$/ ) {
-      push @search, "refnum = $1";
+      push @search, "cust_main.refnum = $1";
       my $part_referral = qsearchs('part_referral', { 'refnum' => $1 } );
       die "unknown refnum $1" unless $part_referral;
       $title = $part_referral->referral. " $title";
@@ -262,7 +262,7 @@ if ( $cgi->param('magic') ) {
     }
 
     if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
-      push @search, "custnum = $1";
+      push @search, "$table.custnum = $1";
     }
 
     if ( $cgi->param('payby') ) {
@@ -421,22 +421,22 @@ if ( $cgi->param('magic') ) {
 
     #for cust_pay_pending...  statusNOT=done
     if ( $cgi->param('statusNOT') =~ /^(\w+)$/ ) {
-      push @search, "status != '$1'";
+      push @search, "$table.status != '$1'";
     }
 
     my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 
-    push @search, "_date >= $beginning ",
-                  "_date <= $ending";
+    push @search, "$table._date >= $beginning ",
+                  "$table._date <= $ending";
 
     if ( $table eq 'cust_pay_void' ) {
       my($v_beginning, $v_ending) =
         FS::UI::Web::parse_beginning_ending($cgi, 'void');
-      push @search, "void_date >= $v_beginning ",
-                    "void_date <= $v_ending";
+      push @search, "$table.void_date >= $v_beginning ",
+                    "$table.void_date <= $v_ending";
     }
 
-    push @search, FS::UI::Web::parse_lt_gt($cgi, $amount_field );
+    push @search, FS::UI::Web::parse_lt_gt($cgi, "$table.$amount_field" );
 
     $orderby = '_date';
 
@@ -517,7 +517,7 @@ if ( $cgi->param('magic') ) {
 
   my $search = ' WHERE '. join(' AND ', @search);
 
-  $count_query = "SELECT COUNT(*), SUM($amount_field) ";
+  $count_query = "SELECT COUNT(*), SUM($table.$amount_field) ";
   $count_query .= ', SUM(' . "FS::$table"->unapplied_sql . ') ' 
     if $unapplied;
   $count_query .= "FROM $table $addl_from".
@@ -545,7 +545,7 @@ if ( $cgi->param('magic') ) {
   $cgi->param('payby') =~ /^(\w+)$/ or die "illegal payby";
   my $payby = $1;
 
-  $count_query = "SELECT COUNT(*), SUM($amount_field) FROM $table".
+  $count_query = "SELECT COUNT(*), SUM($table.$amount_field) FROM $table".
                  "  WHERE payinfo = '$payinfo' AND payby = '$payby'".
                  "  AND ". $curuser->agentnums_sql;
   @count_addl = ( '$%.2f total '.$opt{name_verb} );
index cf5c98a..e47d891 100644 (file)
 %           # One-time charge.  Nothing you can do with this, unless:
 %           if ( $curuser->access_right('Modify one-time charge') ) {
                 (&nbsp;<%onetime_change_link($cust_pkg)%>&nbsp;)
-                <BR>
 %           }
+%           # also, you can discount it
+%           if ( $curuser->access_right('Discount customer package')
+%                && ! scalar($cust_pkg->cust_pkg_discount_active)
+%                && ! scalar($cust_pkg->part_pkg->part_pkg_discount)
+%              ) {
+              (&nbsp;<%pkg_discount_link($cust_pkg)%>&nbsp;)
+%           }
+          <BR>
 %
 %         } elsif ( !$cust_pkg->get('cancel') and !$opt{no_links} ) {
 %
index 6c5c902..b7f7a2c 100644 (file)
@@ -222,6 +222,7 @@ my $format_field = sub {
     $field = $f;
     $type = 'text';
   }
+  warn "$field\t$type\t$value\n";
 
   my $columndef = $part_svc->part_svc_column($field);
   # skip fields that are fixed and empty
@@ -273,8 +274,10 @@ my $format_field = sub {
     $value = time2str("$date_format %H:%M",$value)
   } elsif ( $type eq 'checkbox' ) {
     $value = $value eq 'Y' ? emt('Yes') : emt('No');
-  } elsif ( $type eq 'mac_addr' and $value =~ /\w/) {
-    $value .= ' ('. (Net::MAC::Vendor::lookup($value))->[0]. ')'
+  } elsif ( $type =~ /(input-)?mac_addr/ and $value =~ /\w/) {
+    my $vendor = Net::MAC::Vendor::lookup($value)->[0];
+    $value .= " ($vendor)" if $vendor;
+    $value = $m->scomp('/elements/mac_addr.html', $value);
   }
 
   # 'link' option