summaryrefslogtreecommitdiff
path: root/FS
diff options
context:
space:
mode:
authorIvan Kohler <ivan@freeside.biz>2018-02-09 19:10:00 -0800
committerIvan Kohler <ivan@freeside.biz>2018-02-09 19:10:00 -0800
commitd45dd4a826f314fb5459747590d3e11cd80c211f (patch)
treec1dd2edd4bc42b12cc9a995e95dd7fb630da925e /FS
parent4b67c9f8cfc9f944b7758e7e69ac1f9f188ffa47 (diff)
parent15d596e3090f3bde642917b56563736cd1ee2e90 (diff)
Merge branch 'master' of git.freeside.biz:/home/git/freeside
Diffstat (limited to 'FS')
-rw-r--r--FS/FS/ClientAPI/MyAccount.pm7
-rw-r--r--FS/FS/Mason.pm3
-rw-r--r--FS/FS/Schema.pm93
-rw-r--r--FS/FS/cdr.pm9
-rw-r--r--FS/FS/cdr/Import.pm164
-rw-r--r--FS/FS/cdr/acmepacket.pm168
-rw-r--r--FS/FS/cdr/ani_networks.pm81
-rw-r--r--FS/FS/contact.pm7
-rw-r--r--FS/FS/cust_contact.pm7
-rw-r--r--FS/FS/cust_main.pm99
-rw-r--r--FS/FS/cust_main/Import_Charges.pm57
-rw-r--r--FS/FS/cust_main/Search.pm48
-rw-r--r--FS/FS/cust_main/import_charges/gcet.pm26
-rw-r--r--FS/FS/cust_main/import_charges/ooma.pm21
-rw-r--r--FS/FS/cust_main/import_charges/simple.pm21
-rw-r--r--FS/FS/cust_main_Mixin.pm38
-rw-r--r--FS/FS/h_svc_realestate.pm31
-rw-r--r--FS/FS/msg_template/email.pm29
-rw-r--r--FS/FS/realestate_location.pm177
-rw-r--r--FS/FS/realestate_unit.pm163
-rw-r--r--FS/FS/svc_Common.pm10
-rw-r--r--FS/FS/svc_realestate.pm172
-rwxr-xr-xFS/bin/freeside-cdr-aninetworks-import222
-rw-r--r--FS/bin/freeside-cdr-freeswitch11
-rw-r--r--FS/t/realestate_location.t5
-rw-r--r--FS/t/realestate_unit.t5
-rw-r--r--FS/t/svc_realestate.t5
27 files changed, 1559 insertions, 120 deletions
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index ce887efcd..c00a13f2b 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -237,6 +237,13 @@ sub login {
$svc_x = $svc_phone;
+ } elsif ( $p->{'domain'} eq 'ip_mac' ) {
+
+ my $svc_broadband = qsearchs( 'svc_broadband', { 'mac_addr' => $p->{'username'} } );
+ return { error => 'IP address not found' }
+ unless $svc_broadband;
+ $svc_x = $svc_broadband;
+
} elsif ( $p->{email}
&& (my $contact = FS::contact->by_selfservice_email($p->{email}))
)
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 7bdb6059e..7f883dec1 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -418,6 +418,9 @@ if ( -e $addl_handler_use_file ) {
use FS::part_svc_msgcat;
use FS::commission_schedule;
use FS::commission_rate;
+ use FS::realestate_location;
+ use FS::realestate_unit;
+ use FS::svc_realestate;
use FS::saved_search;
use FS::sector_coverage;
# Sammath Naur
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index b2df048c4..edecb7f38 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -495,8 +495,44 @@ sub tables_hashref {
my $username_len = 64; #usernamemax config file
- # name type nullability length default local
+ # Return a hashref defining the entire application database schema
+ # Each key of the hashref contains a structure describing a database table
+ #
+ # table_name => {
+ # columns => [...],
+ # primary_key => 'column',
+ # unique => [column,column,...],
+ # index => [[column],[column,column],...],
+ # foreign_keys => [{...},{...},...],
+ # }
+ #
+ #
+ # columns => [
+ #
+ # 'column_name',
+ #
+ # 'column_type',
+ #
+ # 'NULL' or '', # 'NULL' : Allow null values
+ # # '' : Disallow null values
+ #
+ # 'length', # Column size value. eg:
+ # # 40 : VARCHAR(40)
+ # # '10,2' : FLOAT(10,2)
+ #
+ # 'default', # Default column value for a new record
+ # # (Unclear if setting this to '' results in a default
+ # # value of NULL or empty string?)
+ #
+ # '', # local ?
+ #
+ # name, type, nullability, length, default, local,
+ # name, type, nullability, length, default, local,
+ # ...
+ #
+ # ],
+ # name type nullability length default local
return {
'agent' => {
@@ -1757,7 +1793,8 @@ sub tables_hashref {
'classnum', 'int', 'NULL', '', '', '',
'comment', 'varchar', 'NULL', 255, '', '',
'selfservice_access', 'char', 'NULL', 1, '', '',
- 'invoice_dest', 'char', 'NULL', 1, '', '',
+ 'invoice_dest', 'char', 'NULL', 1, '', '', # Y or NULL
+ 'message_dest', 'char', 'NULL', 1, '', '', # Y or NULL
],
'primary_key' => 'custcontactnum',
'unique' => [ [ 'custnum', 'contactnum' ], ],
@@ -7598,6 +7635,57 @@ sub tables_hashref {
'foreign_keys' => [],
},
+ 'realestate_unit' => {
+ 'columns' => [
+ 'realestatenum', 'serial', '', '', '', '',
+ 'realestatelocnum', 'int', '', '', '', '',
+ 'agentnum', 'int', 'NULL', '', '', '',
+ 'unit_title', 'varchar', '', $char_d, '', '',
+ 'disabled', 'char', 'NULL', 1, '', '',
+ ],
+ 'primary_key' => 'realestatenum',
+ 'unique' => [ ['unit_title'] ],
+ 'index' => [
+ ['agentnum'],
+ ['realestatelocnum'],
+ ['disabled'],
+ ['unit_title'],
+ ],
+ 'foreign_keys' => [
+ {columns => ['agentnum'], table => 'agent'},
+ {columns => ['realestatelocnum'] => table => 'realestate_location'},
+ ],
+ },
+
+ realestate_location => {
+ 'columns' => [
+ 'realestatelocnum', 'serial', '', '', '', '',
+ 'agentnum', 'int', 'NULL', '', '', '',
+ 'location_title', 'varchar', '', $char_d, '', '',
+ 'address1', 'varchar', 'NULL', $char_d, '', '',
+ 'address2', 'varchar', 'NULL', $char_d, '', '',
+ 'city', 'varchar', 'NULL', $char_d, '', '',
+ 'state', 'varchar', 'NULL', $char_d, '', '',
+ 'zip', 'char', 'NULL', 5, '', '',
+ 'disabled', 'char', 'NULL', 1, '', '',
+ ],
+ primary_key => 'realestatelocnum',
+ 'unique' => [ ['location_title'] ],
+ 'index' => [ ['agentnum'], ['disabled'] ],
+ 'foreign_keys' => [
+ {columns => ['agentnum'], table => 'agent'},
+ ],
+ },
+
+ svc_realestate => {
+ columns => [
+ 'svcnum', 'serial', '', '', '', '',
+ 'realestatenum', 'int', 'NULL', '', '', '',
+ ],
+ primary_key => 'svcnum',
+ index => [],
+ },
+
# name type nullability length default local
#'new_table' => {
@@ -7624,4 +7712,3 @@ L<DBIx::DBSchema>
=cut
1;
-
diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm
index 9ec0f3288..3de022466 100644
--- a/FS/FS/cdr.pm
+++ b/FS/FS/cdr.pm
@@ -1737,6 +1737,14 @@ sub _cdr_date_parse {
# Telos 2014-10-10T05:30:33Z
($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
$options{gmt} = 1;
+ } elsif ( $date =~ /^(\d+):(\d+):(\d+)\.\d+ \w+ (\w+) (\d+) (\d+)$/ ) {
+ ($hour, $min, $sec, $mon, $day, $year) = ( $1, $2, $3, $4, $5, $6 );
+ $mon = { # Acme Packet: 15:54:56.868 PST DEC 18 2017
+ # My best guess of month abbv they may use
+ JAN => '01', FEB => '02', MAR => '03', APR => '04',
+ MAY => '05', JUN => '06', JUL => '07', AUG => '08',
+ SEP => '09', OCT => '10', NOV => '11', DEC => '12'
+ }->{$mon};
} else {
die "unparsable date: $date"; #maybe we shouldn't die...
}
@@ -1901,4 +1909,3 @@ L<FS::Record>, schema.html from the base documentation.
=cut
1;
-
diff --git a/FS/FS/cdr/Import.pm b/FS/FS/cdr/Import.pm
index f32a7bd85..f2263c552 100644
--- a/FS/FS/cdr/Import.pm
+++ b/FS/FS/cdr/Import.pm
@@ -19,15 +19,19 @@ FS::cdr::Import - CDR importing
use FS::cdr::Import;
FS::cdr::Import->dbi_import(
- 'dbd' => 'mysql', #Pg, Sybase, etc.
- 'table' => 'TABLE_NAME',
- 'primary_key' => 'BILLING_ID',
- 'status_table' = > 'STATUS_TABLE_NAME', # if using a table rather than field in main table
- 'column_map' => { #freeside => remote_db
- 'freeside_column' => 'remote_db_column',
- 'freeside_column' => sub { my $row = shift; $row->{remote_db_column}; },
+ 'dbd' => 'Pg', #mysql, Sybase, etc.
+ 'database' => 'DATABASE_NAME',
+ 'table' => 'TABLE_NAME',,
+ 'status_table' => 'STATUS_TABLE_NAME', # if using a table rather than field in main table
+ 'primary_key' => 'BILLING_ID',
+ 'primary_key_info' => 'BIGINT', # defaults to bigint
+ 'status_column' => 'STATUS_COLUMN_NAME', # defaults to freesidestatus
+ 'status_column_info' => 'varchar(32)', # defaults to varchar(32)
+ 'column_map' => { #freeside => remote_db
+ 'freeside_column' => 'remote_db_column',
+ 'freeside_column' => sub { my $row = shift; $row->{remote_db_column}; },
},
- 'batch_name' => 'batch_name', # cdr_batch name -import-date gets appended.
+ 'batch_name' => 'batch_name', # cdr_batch name -import-date gets appended.
);
=head1 DESCRIPTION
@@ -46,61 +50,64 @@ sub dbi_import {
my %opt; #opt is specified for each install / run of the script
getopts('H:U:P:D:T:c:L:S:', \%opt);
- my $user = shift(@ARGV) or die $class->cli_usage;
-
- $opt{D} ||= $args{database};
-
- #do we want to add more types? or add as we go?
- my %dbi_connect_types = {
- 'Sybase' => ':server',
- 'Pg' => ':host',
- };
-
- my $dsn = 'dbi:'. $args{dbd};
- my $dbi_connect_type = $dbi_connect_types{$args{'dbd'}} ? $dbi_connect_types{$args{'dbd'}} : ':host';
- $dsn .= $dbi_connect_type . "=$opt{H}";
- $dsn .= ";database=$opt{D}" if $opt{D};
+ my $user = shift(@ARGV) or die $class->cli_usage;
+ my $database = $opt{D} || $args{database};
+ my $table = $opt{T} || $args{table};
+ my $pkey = $args{primary_key};
+ my $pkey_info = $args{primary_key_info} ? $args{primary_key_info} : 'BIGINT';
+ my $status_table = $opt{S} || $args{status_table};
+ my $dbd_type = $args{'dbd'} ? $args{'dbd'} : 'Pg';
+ my $status_column = $args{status_column} ? $args{status_column} : 'freesidestatus';
+ my $status_column_info = $args{status_column_info} ? $args{status_column} : 'VARCHAR(32)';
+
+ my $queries = get_queries({
+ 'dbd' => $dbd_type,
+ 'host' => $opt{H},
+ 'table' => $table,
+ 'status_column' => $status_column,
+ 'status_column_info' => $status_column_info,
+ 'status_table' => $status_table,
+ 'primary_key' => $pkey,
+ 'primary_key_info' => $pkey_info,
+ });
+
+ my $dsn = 'dbi:'. $dbd_type . $queries->{connect_type};
+ $dsn .= ";database=$database" if $database;
my $dbi = DBI->connect($dsn, $opt{U}, $opt{P})
or die $DBI::errstr;
adminsuidsetup $user;
- #my $fsdbh = FS::UID::dbh;
-
- my $table = $opt{T} || $args{table};
- my $pkey = $args{primary_key};
- my $status_table = $opt{S} || $args{status_table};
-
- #just doing this manually with IVR MSSQL databases for now
- # # check for existence of freesidestatus
- # my $status = $dbi->selectall_arrayref("SHOW COLUMNS FROM $table WHERE Field = 'freesidestatus'");
- # if( ! @$status ) {
- # print "Adding freesidestatus column...\n";
- # $dbi->do("ALTER TABLE $table ADD COLUMN freesidestatus varchar(32)")
- # or die $dbi->errstr;
- # }
- # else {
- # print "freesidestatus column present\n";
- # }
- # or if using a status_table:
- # CREATE TABLE FREESIDE_BILLING (
- # BILLING_ID BIGINT,
- # FREESIDESTATUS VARCHAR(32)
- # )
+ ## check for status table if using. if not there create it.
+ if ($status_table) {
+ my $status = $dbi->selectall_arrayref( $queries->{check_statustable} );
+ if( ! @$status ) {
+ print "Adding status table $status_table ...\n";
+ $dbi->do( $queries->{create_statustable} )
+ or die $dbi->errstr;
+ }
+ }
+ ## check for column freeside status if not using status table and create it if not there.
+ else {
+ my $status = $dbi->selectall_arrayref( $queries->{check_statuscolumn} );
+ if( ! @$status ) {
+ print "Adding $status_column column...\n";
+ $dbi->do( $queries->{create_statuscolumn} )
+ or die $dbi->errstr;
+ }
+ }
#my @cols = values %{ $args{column_map} };
my $sql = "SELECT $table.* FROM $table "; # join(',', @cols). " FROM $table ".
- $sql .= 'LEFT JOIN '. $status_table.
- " ON ( $table.$pkey = ". $status_table. ".$pkey )"
+ $sql .= "LEFT JOIN $status_table ON ( $table.$pkey = $status_table.$pkey ) "
if $status_table;
- $sql .= ' WHERE freesidestatus IS NULL ';
+ $sql .= "WHERE $status_column IS NULL ";
#$sql .= ' LIMIT '. $opt{L} if $opt{L};
my $sth = $dbi->prepare($sql);
$sth->execute or die $sth->errstr. " executing $sql";
- #MySQL-specific print "Importing ".$sth->rows." records...\n";
my $cdr_batch = new FS::cdr_batch({
'cdrbatch' => $args{batch_name} . '-import-'. time2str('%Y/%m/%d-%T',time),
@@ -123,6 +130,7 @@ sub dbi_import {
}
$hash{$field} = '' if $hash{$field} =~ /^\s+$/; #IVR (MSSQL?) bs
}
+
my $cdr = FS::cdr->new(\%hash);
$cdr->cdrtypenum($opt{c}) if $opt{c};
@@ -145,12 +153,12 @@ sub dbi_import {
if ( $status_table ) {
$st_sql =
- 'INSERT INTO '. $status_table. " ( $pkey, freesidestatus ) ".
+ 'INSERT INTO '. $status_table. " ( $pkey, $status_column ) ".
" VALUES ( ?, 'done' )";
} else {
- $st_sql = "UPDATE $table SET freesidestatus = 'done' WHERE $pkey = ?";
+ $st_sql = "UPDATE $table SET $status_column = 'done' WHERE $pkey = ?";
}
@@ -174,15 +182,63 @@ sub dbi_import {
}
sub cli_usage {
- #"Usage: \n $0\n\t[ -H hostname ]\n\t-D database\n\t-U user\n\t-P password\n\tfreesideuser\n";
- #"Usage: \n $0\n\t-H hostname\n\t-D database\n\t-U user\n\t-P password\n\t[ -c cdrtypenum ]\n\tfreesideuser\n";
"Usage: \n $0\n\t-H hostname\n\t[ -D database ]\n\t-U user\n\t-P password\n\t[ -c cdrtypenum ]\n\t[ -L num_cdrs_limit ]\n\t[ -T table ]\n\t[ -S status table ]\n\tfreesideuser\n";
}
+sub get_queries {
+ #my ($dbd, $host, $table, $column, $column_create_info, $status_table, $primary_key, $primary_key_info) = @_;
+ my $info = shift;
+
+ #get host and port information.
+ my ($host, $port) = split /:/, $info->{host};
+ $host ||= 'localhost';
+ $port ||= '5000'; # check for pg default 5000 is sybase.
+
+ my %dbi_connect_types = (
+ 'Sybase' => ':host='.$host.';port='.$port,
+ 'Pg' => ':host='.$info->{host},
+ );
+
+ #Check for freeside status table
+ my %dbi_check_statustable = (
+ 'Sybase' => "SELECT * FROM sysobjects WHERE name = '$info->{status_table}'",
+ 'Pg' => "SELECT * FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '$info->{status_table}' AND column_name = '$info->{status_column}'",
+ );
+
+ #Create freeside status table
+ my %dbi_create_statustable = (
+ 'Sybase' => "CREATE TABLE $info->{status_table} ( $info->{primary_key} $info->{primary_key_info}, $info->{status_column} $info->{status_column_info} )",
+ 'Pg' => "CREATE TABLE $info->{status_table} ( $info->{primary_key} $info->{primary_key_info}, $info->{status_column} $info->{status_column_info} )",
+ );
+
+ #Check for freeside status column
+ my %dbi_check_statuscolumn = (
+ 'Sybase' => "SELECT syscolumns.name FROM sysobjects
+ JOIN syscolumns ON sysobjects.id = syscolumns.id
+ WHERE sysobjects.name LIKE '$info->{table}' AND syscolumns.name = '$info->{status_column}'",
+ 'Pg' => "SELECT * FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '$info->{table}' AND column_name = '$info->{status_column}' ",
+ );
+
+ #Create freeside status column
+ my %dbi_create_statuscolumn = (
+ 'Sybase' => "ALTER TABLE $info->{table} ADD $info->{status_column} $info->{status_column_info} NULL",
+ 'Pg' => "ALTER TABLE $info->{table} ADD COLUMN $info->{status_column} $info->{status_column_info}",
+ );
+
+ my $queries = {
+ 'connect_type' => $dbi_connect_types{$info->{dbd}},
+ 'check_statustable' => $dbi_check_statustable{$info->{dbd}},
+ 'create_statustable' => $dbi_create_statustable{$info->{dbd}},
+ 'check_statuscolumn' => $dbi_check_statuscolumn{$info->{dbd}},
+ 'create_statuscolumn' => $dbi_create_statuscolumn{$info->{dbd}},
+ };
+
+ return $queries;
+}
+
=head1 BUGS
-Not everything has been refactored out of the various bin/cdr-*.import scripts,
-let alone other places.
+currently works with Pg(Postgresql) and Sybase(Sybase AES)
Sparse documentation.
diff --git a/FS/FS/cdr/acmepacket.pm b/FS/FS/cdr/acmepacket.pm
new file mode 100644
index 000000000..1f00e4086
--- /dev/null
+++ b/FS/FS/cdr/acmepacket.pm
@@ -0,0 +1,168 @@
+package FS::cdr::acmepacket;
+
+=head1 NAME
+
+FS:cdr::acmepacket - CDR import definition based on Acme Packet Net-Net 4000
+
+=head1 DESCRIPTION
+
+The Acme Packet NetNet 4000 S-CX6.4.0 generates an odd cdr log format:
+
+ - Each row in the CSV may be in one of many various formats, some of
+ them undocumented.
+ - Columns are inconsistently enclosed with " characters
+ - Quoted column values may, themselves, contain unescaped quote marks.
+ This breaks Text::CSV (well technically the FORMAT is broken, not
+ Text::CSV).
+ - A single call can generate multiple CDR records. The only records we're
+ interested in are billable calls:
+ - These are called "Stop Record CSV Placement" in Acme Packet documentation
+ - These will always contain a "2" as the first column value
+ - These rows may be 175 or 269 fields in length. It's unclear if the
+ undocumented 269 column rows are an intentional Acme Packet format, or
+ a bug in their switch. The extra columns are inserted at idx 115,
+ and can safely be disregarded.
+
+NOTE ON DATE PARSING:
+
+ The Acme Packet manual doesn't describe it's date format in detail. The sample
+ we were given contains only records from December. Dates are formatted like
+ so: 15:54:56.868 PST DEC 18 2017
+
+ I gave my best guess how they will format the month text in the parser
+ FS::cdr::_cdr_date_parse(). If this format doesn't import records on a
+ particular month, check there.
+
+=cut
+
+use strict;
+use warnings;
+use vars qw(%info);
+use base qw(FS::cdr);
+use FS::cdr qw(_cdr_date_parse);
+use Text::CSV;
+
+my $DEBUG = 0;
+
+my $cbcsv = Text::CSV->new({binary => 1})
+ or die "Error loading Text::CSV - ".Text::CSV->error_diag();
+
+# Used to map source format into the contrived format created for import_fields
+# $cdr_premap[ IMPORT_FIELDS_IDX ] = SOURCE_FORMAT_IDX
+my @cdr_premap = (
+ 9, # clid
+ 9, # src
+ 10, # dst
+ 22, # channel
+ 21, # dstchannel
+ 26, # src_ip_addr
+ 28, # dst_ip_addr
+ 13, # startdate
+ 14, # answerdate
+ 15, # enddate
+ 12, # duration
+ 12, # billsec
+ 3, # userfield
+);
+
+our %info = (
+ name => 'Acme Packet',
+ weight => 600,
+ header => 0,
+ type => 'csv',
+
+ import_fields => [
+ # freeside # [idx] acmepacket
+ 'clid', # 9 Calling-Station-Id
+ 'src', # 9 Calling-Station-Id
+ 'dst', # 10 Called-Station-Id
+ 'channel', # 22 Acme-Session-Ingress-Realm
+ 'dstchannel', # 23 Acme-Session-Egress-Realm
+ 'src_ip_addr', # 26 Acme-Flow-In-Src-Adr_FS1_f
+ 'dst_ip_addr', # 28 Acme-Flow-In-Dst-Addr_FS1_f
+ 'startdate', # 13 h323-setup-time
+ 'answerdate', # 14 h323-connect-time
+ 'enddate', # 15 h323-disconnect-time
+ 'duration', # 12 Acct-Session-Time
+ 'billsec', # 12 Acct-Session-Time
+ 'userfield', # 3 Acct-Session-Id
+ ],
+
+ row_callback => sub {
+ my $line = shift;
+
+ # Only process records whose first column contains a 2
+ return undef unless $line =~ /^2\,/;
+
+ # Replace unescaped quote characters within quote-enclosed text cols
+ $line =~ s/(?<!\,)\"(?!\,)/\'/g;
+
+ unless( $cbcsv->parse($line) ) {
+ warn "Text::CSV Error parsing Acme Packet CDR: ".$cbcsv->error_diag();
+ return undef;
+ }
+
+ my @row = $cbcsv->fields();
+ if (@row == 269) {
+ # Splice out the extra columns
+ @row = (@row[0..114], @row[209..@row-1]);
+ } elsif (@row != 175) {
+ warn "Unknown row format parsing Acme Packet CDR";
+ return undef;
+ }
+
+ my @out = map{ $row[$_] } @cdr_premap;
+
+ if ($DEBUG) {
+ warn "~~~~~~~~~~pre-processed~~~~~~~~~~~~~~~~ \n";
+ warn "$_ $out[$_] \n" for (0..@out-1);
+ }
+
+ # answerdate, enddate, startdate
+ for (7,8,9) {
+ $out[$_] = _cdr_date_parse($out[$_]);
+ if ($out[$_] =~ /\D/) {
+ warn "Unparsable date in Acme Packet CDR: ($out[$_])";
+ return undef;
+ }
+ }
+
+ # clid, dst, src CDR field formatted as one of the following:
+ # 'WIRELESS CALLER'<sip:12513001300@4.2.2.2:5060;user=phone>;tag=SDepng302
+ # <sip:12513001300@4.2.2.2:5060;user=phone>;tag=SDepng302
+ # '12513001300'<sip:4.2.2.2:5060;user=phone>;tag=SDepng302
+
+ # clid
+ $out[0] = $out[0] =~ /^\'(.+)\'/ ? $1 : "";
+
+ # src, dst
+ # Use the 7+ digit number as the src/dst phone number.
+ # Prefer using the number within <sip> label. If there is not one,
+ # allow using number from caller-id text field.
+ for (1,2) {
+ my $f = $out[$_];
+ $out[$_] =~ s/^\'.+\'//g; # strip caller id label portion
+ if ($out[$_] =~ /(\d{7,})/) {
+ # Using number from <sip>
+ $out[$_] = $1;
+ } elsif ($f =~ /(\d{7,})/) {
+ # Using number from caller-id text
+ $out[$_] = $1;
+ } else {
+ # No phone number, perhaps an IP only call
+ $out[$_] = undef;
+ }
+ }
+
+ if ($DEBUG) {
+ warn "~~~~~~~~~~post-processed~~~~~~~~~~~~~~~~ \n";
+ warn "$_ $out[$_] \n" for (0..@out-1);
+ }
+
+ # I haven't seen commas in sample data text fields. Extra caution,
+ # mangle commas into periods as we pass back to importer
+ join ',', map{ $_ =~ s/\,/\./g; $_ } @out;
+ },
+);
+
+1;
diff --git a/FS/FS/cdr/ani_networks.pm b/FS/FS/cdr/ani_networks.pm
new file mode 100644
index 000000000..cac30c488
--- /dev/null
+++ b/FS/FS/cdr/ani_networks.pm
@@ -0,0 +1,81 @@
+package FS::cdr::ani_networks;
+use base qw( FS::cdr );
+
+use strict;
+use vars qw( %info );
+use Time::Local;
+
+%info = (
+ 'name' => 'ANI NETWORKS',
+ 'weight' => 60,
+ 'type' => 'fixedlength',
+ 'fixedlength_format' => [qw(
+ call_date_time:14:1:14
+ bill_to_number:15:15:29
+ translate_number:10:30:39
+ originating_number:10:40:49
+ originating_lata:3:50:52
+ originating_city:30:53:82
+ originating_state:2:83:84
+ originating_country:4:85:88
+ terminating_number:15:89:103
+ terminating_lata:3:104:106
+ terminating_city:30:107:136
+ terminating_state:2:137:138
+ terminating_citycode:3:139:141
+ terminating_country:4:142:145
+ call_type:2:146:147
+ call_transport:1:148:148
+ account_code:12:149:160
+ info_digits:2:161:162
+ duration:8:163:170
+ wholesale_amount:9:171:179
+ cic:4:180:183
+ originating_lrn:10:184:193
+ terminating_lrn:10:194:203
+ originating_ocn:4:204:207
+ terminating_ocn:4:208:211
+ )],
+ 'import_fields' => [
+
+ sub { #call_date and time
+ my($cdr, $data, $conf, $param) = @_;
+ $data =~ /^(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)$/ or die "unparsable record_date: $data";
+ $cdr->set('calldate', "$2/$3/$1 $4:$5:$6");
+ },
+
+ 'charged_party', #bill to number
+ '', #translate number
+
+ 'src', #originating number
+
+ '', #originating lata
+ '', #originating city
+ '', #originating state
+ '', #originating country
+
+ 'dst', #terminating number
+
+ '', #terminating lata
+ '', #terminating city
+ '', #terminating state
+ '', #terminating city code
+ '', #terminating country
+
+ '', #call type
+ '', #call transport
+ 'accountcode', #account code
+ '', #info digits
+ 'duration', #duration
+ '', #wholesale amount
+ '', #cic
+ 'src_lrn', #originating lrn
+ 'dst_lrn', #terminating lrn
+ '', #originating ocn
+ '', #terminating ocn
+
+ ],
+
+);
+
+1; \ No newline at end of file
diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm
index 44c538806..fa047f59d 100644
--- a/FS/FS/contact.pm
+++ b/FS/FS/contact.pm
@@ -155,7 +155,7 @@ sub insert {
$self->custnum('');
my %link_hash = ();
- for (qw( classnum comment selfservice_access invoice_dest )) {
+ for (qw( classnum comment selfservice_access invoice_dest message_dest)) {
$link_hash{$_} = $self->get($_);
$self->$_('');
}
@@ -430,7 +430,7 @@ sub replace {
$self->custnum('');
my %link_hash = ();
- for (qw( classnum comment selfservice_access invoice_dest )) {
+ for (qw( classnum comment selfservice_access invoice_dest message_dest )) {
$link_hash{$_} = $self->get($_);
$self->$_('');
}
@@ -955,7 +955,7 @@ sub cgi_contact_fields {
my @contact_fields = qw(
classnum first last title comment emailaddress selfservice_access
- invoice_dest password
+ invoice_dest message_dest password
);
push @contact_fields, 'phonetypenum'. $_->phonetypenum
@@ -1028,4 +1028,3 @@ L<FS::Record>, schema.html from the base documentation.
=cut
1;
-
diff --git a/FS/FS/cust_contact.pm b/FS/FS/cust_contact.pm
index 118a9e000..adad46e9e 100644
--- a/FS/FS/cust_contact.pm
+++ b/FS/FS/cust_contact.pm
@@ -59,6 +59,11 @@ empty or Y
'Y' if the customer should get invoices sent to this address, null if not
+=item message_dest
+
+'Y' if contact should get non-invoice email messages sent to this address,
+NULL if not
+
=back
=head1 METHODS
@@ -119,6 +124,7 @@ sub check {
|| $self->ut_textn('comment')
|| $self->ut_enum('selfservice_access', [ '', 'Y' ])
|| $self->ut_flag('invoice_dest')
+ || $self->ut_flag('message_dest')
;
return $error if $error;
@@ -148,4 +154,3 @@ L<FS::contact>, L<FS::cust_main>, L<FS::Record>
=cut
1;
-
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 925eb4e44..7c9868d7a 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -3022,48 +3022,104 @@ sub invoicing_list_emailonly_scalar {
join(', ', $self->invoicing_list_emailonly);
}
-=item contact_list [ CLASSNUM, ... ]
+=item contact_list [ CLASSNUM, DEST_FLAG... ]
-Returns a list of contacts (L<FS::contact> objects) for the customer. If
-a list of contact classnums is given, returns only contacts in those
-classes. If the pseudo-classnum 'invoice' is given, returns contacts that
-are marked as invoice destinations. If '0' is given, also returns contacts
-with no class.
+Returns a list of contacts (L<FS::contact> objects) for the customer.
If no arguments are given, returns all contacts for the customer.
+Arguments may contain classnums. When classnums are specified, only
+contacts with a matching cust_contact.classnum are returned. When a
+classnum of 0 is given, contacts with a null classnum are also included.
+
+Arguments may also contain the dest flag names 'invoice' or 'message'.
+If given, contacts who's invoice_dest and/or message_dest flags are
+not set to 'Y' will be excluded.
+
=cut
sub contact_list {
my $self = shift;
my $search = {
table => 'contact',
- select => 'contact.*, cust_contact.invoice_dest',
+ select => join(', ',(
+ 'contact.*',
+ 'cust_contact.invoice_dest',
+ 'cust_contact.message_dest',
+ )),
addl_from => ' JOIN cust_contact USING (contactnum)',
extra_sql => ' WHERE cust_contact.custnum = '.$self->custnum,
};
- my @orwhere;
+ # Bugfix notes:
+ # Calling methods were relying on this method to use invoice_dest to
+ # block e-mail messages. Depending on parameters, this may or may not
+ # have actually happened.
+ #
+ # The bug could cause this SQL to be used to filter e-mail addresses:
+ #
+ # AND (
+ # cust_contact.classnums IN (1,2,3)
+ # OR cust_contact.invoice_dest = 'Y'
+ # )
+ #
+ # improperly including everybody with the opt-in flag AND everybody
+ # in the contact classes
+ #
+ # Possibility to introduce new bugs:
+ # If callers of this method called it incorrectly, and didn't notice
+ # because it seemed to send the e-mails they wanted.
+
+ # WHERE ...
+ # AND (
+ # (
+ # cust_contact.classnum IN (1,2,3)
+ # OR
+ # cust_contact.classnum IS NULL
+ # )
+ # AND (
+ # cust_contact.invoice_dest = 'Y'
+ # OR
+ # cust_contact.message_dest = 'Y'
+ # )
+ # )
+
+ my @and_dest;
+ my @or_classnum;
my @classnums;
- foreach (@_) {
- if ( $_ eq 'invoice' ) {
- push @orwhere, 'cust_contact.invoice_dest = \'Y\'';
- } elsif ( $_ eq '0' ) {
- push @orwhere, 'cust_contact.classnum is null';
+ for (@_) {
+ if ($_ eq 'invoice' || $_ eq 'message') {
+ push @and_dest, " cust_contact.${_}_dest = 'Y' ";
+ } elsif ($_ eq '0') {
+ push @or_classnum, ' cust_contact.classnum IS NULL ';
} elsif ( /^\d+$/ ) {
push @classnums, $_;
} else {
- die "bad classnum argument '$_'";
+ croak "bad classnum argument '$_'";
}
}
- if (@classnums) {
- push @orwhere, 'cust_contact.classnum IN ('.join(',', @classnums).')';
- }
- if (@orwhere) {
- $search->{extra_sql} .= ' AND (' .
- join(' OR ', map "( $_ )", @orwhere) .
- ')';
+ push @or_classnum, 'cust_contact.classnum IN ('.join(',',@classnums).')'
+ if @classnums;
+
+ if (@or_classnum || @and_dest) { # catch, no arguments given
+ $search->{extra_sql} .= ' AND ( ';
+
+ if (@or_classnum) {
+ $search->{extra_sql} .= ' ( ';
+ $search->{extra_sql} .= join ' OR ', map {" $_ "} @or_classnum;
+ $search->{extra_sql} .= ' ) ';
+ $search->{extra_sql} .= ' AND ( ' if @and_dest;
+ }
+
+ if (@and_dest) {
+ $search->{extra_sql} .= join ' OR ', map {" $_ "} @and_dest;
+ $search->{extra_sql} .= ' ) ' if @or_classnum;
+ }
+
+ $search->{extra_sql} .= ' ) ';
+
+ warn "\$extra_sql: $search->{extra_sql} \n" if $DEBUG;
}
qsearch($search);
@@ -5540,4 +5596,3 @@ L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.
=cut
1;
-
diff --git a/FS/FS/cust_main/Import_Charges.pm b/FS/FS/cust_main/Import_Charges.pm
index 0a12c8752..3d2031d45 100644
--- a/FS/FS/cust_main/Import_Charges.pm
+++ b/FS/FS/cust_main/Import_Charges.pm
@@ -8,6 +8,48 @@ use FS::UID qw( dbh );
use FS::CurrentUser;
use FS::Record qw( qsearchs );
use FS::cust_main;
+use FS::Conf;
+
+my $DEBUG = '';
+
+my %import_charges_info;
+foreach my $INC ( @INC ) {
+ warn "globbing $INC/FS/cust_main/import_charges/[a-z]*.pm\n" if $DEBUG;
+ foreach my $file ( glob("$INC/FS/cust_main/import_charges/[a-z]*.pm") ) {
+ warn "attempting to load import charges format info from $file\n" if $DEBUG;
+ $file =~ /\/(\w+)\.pm$/ or do {
+ warn "unrecognized file in $INC/FS/cust_main/import_charges/: $file\n";
+ next;
+ };
+ my $mod = $1;
+ my $info = eval "use FS::cust_main::import_charges::$mod; ".
+ "\\%FS::cust_main::import_charges::$mod\::info;";
+ if ( $@ ) {
+ die "error using FS::cust_main::import_charges::$mod (skipping): $@\n" if $@;
+ next;
+ }
+ unless ( keys %$info ) {
+ warn "no %info hash found in FS::cust_main::import_charges::$mod, skipping\n";
+ next;
+ }
+ warn "got import charges format info from FS::cust_main::import_charges::$mod: $info\n" if $DEBUG;
+ if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
+ warn "skipping disabled import charges format FS::cust_main::import_charges::$mod" if $DEBUG;
+ next;
+ }
+ $import_charges_info{$mod} = $info;
+ }
+}
+
+tie my %import_formats, 'Tie::IxHash',
+ map { $_ => $import_charges_info{$_}->{'name'} }
+ sort { $import_charges_info{$a}->{'weight'} <=> $import_charges_info{$b}->{'weight'} }
+ grep { exists($import_charges_info{$_}->{'fields'}) }
+ keys %import_charges_info;
+
+sub import_formats {
+ %import_formats;
+}
=head1 NAME
@@ -65,17 +107,10 @@ sub batch_charge {
my @fields;
my %charges;
- if ( $format eq 'simple' ) {
- @fields = qw( custnum agent_custid amount pkg );
- } elsif ( $format eq 'ooma' ) {
- @fields = ( 'userfield1', 'userfield2', 'userfield3', 'userfield4', 'userfield5', 'userfield6', 'userfield7', 'userfield8', 'userfield9', 'userfield10', 'amount', 'userfield12', 'userfield13', 'userfield14', 'userfield15', 'userfield16', 'userfield17', 'userfield18', 'pkg', 'userfield20', 'custnum', 'userfield22', 'userfield23', 'userfield24', 'userfield25', );
- ##should charges to charge be a config option?
- %charges = (
- 'DISABILITY ACCESS/ENHANCED 911 SERVICES SURCHARGE' => '1',
- 'FEDERAL TRS FUND' => '1',
- 'FEDERAL UNIVERSAL SERVICE FUND' => '1',
- 'STATE SALES TAX' => '1',
- );
+
+ if ( $import_charges_info{$format} ) {
+ @fields = @{$import_charges_info{$format}->{'fields'}};
+ %charges = %{$import_charges_info{$format}->{'charges'}};
} else {
die "unknown format $format";
}
diff --git a/FS/FS/cust_main/Search.pm b/FS/FS/cust_main/Search.pm
index 2ec87cd14..815304bb4 100644
--- a/FS/FS/cust_main/Search.pm
+++ b/FS/FS/cust_main/Search.pm
@@ -1,6 +1,7 @@
package FS::cust_main::Search;
use strict;
+use Carp qw( croak );
use base qw( Exporter );
use vars qw( @EXPORT_OK $DEBUG $me $conf @fuzzyfields );
use String::Approx qw(amatch);
@@ -804,15 +805,51 @@ sub search {
unless $params->{'cancelled_pkgs'};
##
- # "with email address(es)" checkbox
+ # "with email address(es)" checkbox,
+ # also optionally: with_email_dest and with_contact_type
##
- push @where,
- 'EXISTS ( SELECT 1 FROM contact_email
+ if ($params->{with_email}) {
+ my @email_dest;
+ my $email_dest_sql;
+ my $contact_type_sql;
+
+ if ($params->{with_email_dest}) {
+ croak unless ref $params->{with_email_dest} eq 'ARRAY';
+
+ @email_dest = @{$params->{with_email_dest}};
+ $email_dest_sql =
+ " AND ( ".
+ join(' OR ',map(" cust_contact.${_}_dest IS NOT NULL ", @email_dest)).
+ " ) ";
+ # Can't use message_dist = 'Y' because single quotes are escaped later
+ }
+ if ($params->{with_contact_type}) {
+ croak unless ref $params->{with_contact_type} eq 'ARRAY';
+
+ my @contact_type = grep {/^\d+$/ && $_ > 0} @{$params->{with_contact_type}};
+ my $has_null_type = 0;
+ $has_null_type = 1 if grep { $_ eq 0 } @{$params->{with_contact_type}};
+ my $hnt_sql;
+ if ($has_null_type) {
+ $hnt_sql = ' OR ' if @contact_type;
+ $hnt_sql .= ' cust_contact.classnum IS NULL ';
+ }
+
+ $contact_type_sql =
+ " AND ( ".
+ join(' OR ', map(" cust_contact.classnum = $_ ", @contact_type)).
+ $hnt_sql.
+ " ) ";
+ }
+ push @where,
+ "EXISTS ( SELECT 1 FROM contact_email
JOIN cust_contact USING (contactnum)
WHERE cust_contact.custnum = cust_main.custnum
- )'
- if $params->{'with_email'};
+ $email_dest_sql
+ $contact_type_sql
+ ) ";
+ }
##
# "with postal mail invoices" checkbox
@@ -1390,4 +1427,3 @@ L<FS::cust_main>, L<FS::Record>
=cut
1;
-
diff --git a/FS/FS/cust_main/import_charges/gcet.pm b/FS/FS/cust_main/import_charges/gcet.pm
new file mode 100644
index 000000000..83f956545
--- /dev/null
+++ b/FS/FS/cust_main/import_charges/gcet.pm
@@ -0,0 +1,26 @@
+package FS::cust_main::import_charges::gcet;
+
+use strict;
+use base qw( FS::cust_main::Import_Charges );
+use vars qw ( %info );
+
+# gcet fields.
+my @fields = ( 'userfield1', 'userfield2', 'userfield3', 'userfield4', 'userfield5', 'userfield6', 'userfield7', 'userfield8', 'userfield9', 'userfield10', 'amount', 'userfield12', 'userfield13', 'userfield14', 'userfield15', 'userfield16', 'userfield17', 'userfield18', 'pkg', 'userfield20', 'custnum', 'userfield22', 'userfield23', 'userfield24', 'userfield25', );
+# hash of charges (pkg) to charge. if empty charge them all.
+# '911 services' => '1',
+my $charges = {
+ 'DISABILITY ACCESS/ENHANCED 911 SERVICES SURCHARGE' => '1',
+ 'FEDERAL TRS FUND' => '1',
+ 'FEDERAL UNIVERSAL SERVICE FUND' => '1',
+ 'STATE SALES TAX' => '1',
+};
+
+%info = (
+ 'fields' => [@fields],
+ 'charges' => $charges,
+ 'name' => 'Gcet',
+ 'weight' => '30',
+ 'disabled' => '1',
+);
+
+1; \ No newline at end of file
diff --git a/FS/FS/cust_main/import_charges/ooma.pm b/FS/FS/cust_main/import_charges/ooma.pm
new file mode 100644
index 000000000..a43def239
--- /dev/null
+++ b/FS/FS/cust_main/import_charges/ooma.pm
@@ -0,0 +1,21 @@
+package FS::cust_main::import_charges::ooma;
+
+use strict;
+use base qw( FS::cust_main::Import_Charges );
+use vars qw ( %info );
+
+# ooma fields
+my @fields = ('userfield1', 'userfield2', 'userfield3', 'userfield4', 'userfield5', 'userfield6', 'userfield7', 'userfield8', 'amount', 'userfield10', 'userfield11', 'userfield12', 'userfield13', 'userfield14', 'userfield15', 'userfield16', 'pkg', 'userfield18', 'custnum', 'userfield20', 'userfield21', 'userfield22', 'userfield23', 'userfield24', 'userfield25', );
+# hash of charges (pkg) to charge. if empty charge them all.
+# '911 services' => '1',
+my $charges = {};
+
+%info = (
+ 'fields' => [@fields],
+ 'charges' => $charges,
+ 'name' => 'Ooma',
+ 'weight' => '10',
+ 'disabled' => '',
+);
+
+1; \ No newline at end of file
diff --git a/FS/FS/cust_main/import_charges/simple.pm b/FS/FS/cust_main/import_charges/simple.pm
new file mode 100644
index 000000000..e039328ba
--- /dev/null
+++ b/FS/FS/cust_main/import_charges/simple.pm
@@ -0,0 +1,21 @@
+package FS::cust_main::import_charges::simple;
+
+use strict;
+use base qw( FS::cust_main::Import_Charges );
+use vars qw ( %info );
+
+# simple field format
+my @fields = ('custnum', 'agent_custid', 'amount', 'pkg');
+# hash of charges (pkg) to charge. if empty charge them all.
+# '911 services' => '1',
+my $charges = {};
+
+%info = (
+ 'fields' => [@fields],
+ 'charges' => $charges,
+ 'name' => 'Simple',
+ 'weight' => '1',
+ 'disabled' => '',
+);
+
+1; \ No newline at end of file
diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm
index 8b6569a74..cceaa4bc7 100644
--- a/FS/FS/cust_main_Mixin.pm
+++ b/FS/FS/cust_main_Mixin.pm
@@ -348,10 +348,21 @@ sub cust_search_sql {
=item email_search_result HASHREF
-Emails a notice to the specified customers. Customers without
-invoice email destinations will be skipped.
+Emails a notice to the specified customer's contact_email addresses.
-Parameters:
+
+If the user has specified "Invoice recipients" on the send e-mail screen,
+contact_email rows containing the invoice_dest flag will be included.
+This option is default, if neither 'invoice' nor 'message' are present.
+
+If the user has specified "Message recipients" on the send e-mail screen,
+contact_email rows containing the message_dest flag will be included.
+
+The selection is indicated by the presence of the text 'message' or
+'invoice' within the to_contact_classnum argument.
+
+
+Parameters:
=over 4
@@ -386,9 +397,19 @@ Text body
=item to_contact_classnum
-The customer contact class (or classes, as a comma-separated list) to send
-the message to. If unspecified, will be sent to any contacts that are marked
-as invoice destinations (the equivalent of specifying 'invoice').
+This field contains a comma-separated list. This list may contain:
+
+- the text "invoice" indicating contacts with invoice_dest flag should
+ be included
+- the text "message" indicating contacts with message_dest flag should
+ be included
+- numbers representing classnum id values for email contact classes.
+ If any classnum are present, emails should only be sent to contact_email
+ addresses where contact_email.classnum contains one of these classes.
+ The classnum 0 also includes where contact_email.classnum IS NULL
+
+If neither 'invoice' nor 'message' has been specified, this method will
+behave as if 'invoice' had been selected
=back
@@ -483,8 +504,8 @@ sub email_search_result {
next; # unlinked object; nothing else we can do
}
-my %to = {};
-if ($to) { $to{'to'} = $to; }
+ my %to = ();
+ if ($to) { $to{'to'} = $to; }
my $cust_msg = $msg_template->prepare(
'cust_main' => $cust_main,
@@ -736,4 +757,3 @@ L<FS::cust_main>, L<FS::Record>
=cut
1;
-
diff --git a/FS/FS/h_svc_realestate.pm b/FS/FS/h_svc_realestate.pm
new file mode 100644
index 000000000..2fdd291d1
--- /dev/null
+++ b/FS/FS/h_svc_realestate.pm
@@ -0,0 +1,31 @@
+package FS::h_svc_realestate;
+
+use strict;
+use vars qw( @ISA );
+use FS::h_Common;
+
+
+@ISA = qw( FS::h_Common );
+
+sub table { 'h_svc_realestate' };
+
+=head1 NAME
+
+FS::h_svc_circuit - Historical realestate service objects
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+An FS::h_svc_realestate object
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::h_Common>, L<FS::svc_realestate>, L<FS::Record>, schema.html from the base
+documentation.
+
+=cut
+
+1;
diff --git a/FS/FS/msg_template/email.pm b/FS/FS/msg_template/email.pm
index 4ae89f056..37c1fab46 100644
--- a/FS/FS/msg_template/email.pm
+++ b/FS/FS/msg_template/email.pm
@@ -210,6 +210,22 @@ go away in the future.
A L<MIME::Entity> (or arrayref of them) to attach to the message.
+=item to_contact_classnum
+
+Set a string containing a comma-separated list. This list may contain:
+
+- the text "invoice" indicating contacts with invoice_dest flag should
+ be included
+- the text "message" indicating contacts with message_dest flag should
+ be included
+- numbers representing classnum id values for email contact classes.
+ If any classnum are present, emails should only be sent to contact_email
+ addresses where contact_email.classnum contains one of these classes.
+ The classnum 0 also includes where contact_email.classnum IS NULL
+
+If neither 'invoice' nor 'message' has been specified, this method will
+behave as if 'invoice' had been selected
+
=cut
=back
@@ -296,8 +312,16 @@ sub prepare {
my $classnum = $opt{'to_contact_classnum'} || '';
my @classes = ref($classnum) ? @$classnum : split(',', $classnum);
- # traditional behavior: send to all invoice recipients
- @classes = ('invoice') unless @classes;
+
+ # There are two e-mail opt-in flags per contact_email address.
+ # If neither 'invoice' nor 'message' has been specified, default
+ # to 'invoice'.
+ #
+ # This default supports the legacy behavior of
+ # send to all invoice recipients
+ push @classes,'invoice'
+ unless grep {$_ eq 'invoice' || $_ eq 'message'} @classes;
+
@to = $cust_main->contact_list_email(@classes);
# not guaranteed to produce contacts, but then customers aren't
# guaranteed to have email addresses on file. in that case, env_to
@@ -625,4 +649,3 @@ L<FS::Record>, schema.html from the base documentation.
=cut
1;
-
diff --git a/FS/FS/realestate_location.pm b/FS/FS/realestate_location.pm
new file mode 100644
index 000000000..d9cd76a58
--- /dev/null
+++ b/FS/FS/realestate_location.pm
@@ -0,0 +1,177 @@
+package FS::realestate_location;
+use strict;
+use warnings;
+use Carp qw(croak);
+
+use base 'FS::Record';
+
+use FS::Record qw(qsearchs qsearch);
+
+=head1 NAME
+
+FS::realestate_location - Object representing a realestate_location record
+
+=head1 SYNOPSIS
+
+ use FS::realestate_location;
+
+ $location = new FS::realestate_location \%values;
+ $location = new FS::realestate_location {
+ agentnum => 1,
+ location_title => 'Superdome',
+ address1 => '1500 Sugar Bowl Dr',
+ city => 'New Orleans',
+ state => 'LA',
+ zip => '70112',
+ };
+
+ $error = $location->insert;
+ $error = $new_loc->replace($location);
+ $error = $record->check;
+
+ $error = $location->add_unit('Box Seat No. 42');
+ @units = $location->units;
+
+=head1 DESCRIPTION
+
+An FS::realestate_location object represents a location for one or more
+FS::realestate_unit objects. Expected to contain at least one unit, as only
+realestate_unit objects are assignable to packages via
+L<FS::svc_realestate>.
+
+FS::realestate_location inherits from FS::Record.
+
+The following fields are currently supported:
+
+=over 4
+
+=item realestatelocnum
+
+=item agentnum
+
+=item location_title
+
+=item address1 (optional)
+
+=item address2 (optional)
+
+=item city (optional)
+
+=item state (optional)
+
+=item zip (optional)
+
+=item disabled
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF (see L<FS::Record>)
+
+=cut
+
+sub table {'realestate_location';}
+
+=item insert (see L<FS::Record>)
+
+=item delete
+
+ FS::realestate_location records should never be deleted, only disabled
+
+=cut
+
+sub delete {
+ # Once this record has been associated with a customer in any way, it
+ # should not be deleted. todo perhaps, add a is_deletable function that
+ # checks if the record has ever actually been used, and allows deletion
+ # if it hasn't. (entered in error, etc).
+ croak "FS::realestate_location records should never be deleted";
+}
+
+=item replace OLD_RECORD (see L<FS::Record>)
+
+=item check (see L<FS::Record>)
+
+=item agent
+
+Returns the associated agent
+
+=cut
+
+sub agent {
+ my $self = shift;
+ return undef unless $self->agentnum;
+ return exists $self->{agent}
+ ? $self->{agent}
+ : $self->{agent} = qsearchs('agent', {agentnum => $self->agentnum} );
+}
+
+
+=item add_unit UNIT_TITLE
+
+Create an associated L<FS::realestate_unit> record
+
+=cut
+
+sub add_unit {
+ my ($self, $unit_title) = @_;
+ croak "add_unit() requires a \$unit_title parameter" unless $unit_title;
+
+ if (
+ qsearchs('realestate_unit',{
+ realestatelocnum => $self->realestatelocnum,
+ unit_title => $unit_title,
+ })
+ ) {
+ return "Unit Title ($unit_title) has already been used for location (".
+ $self->location_title.")";
+ }
+
+ my $unit = FS::realestate_unit->new({
+ realestatelocnum => $self->realestatelocnum,
+ agentnum => $self->agentnum,
+ unit_title => $unit_title,
+ });
+ my $err = $unit->insert;
+ die "Error creating FS::realestate_new record: $err" if $err;
+
+ return;
+}
+
+
+=item units
+
+Returns all units associated with this location
+
+=cut
+
+sub units {
+ my $self = shift;
+ return qsearch(
+ 'realestate_unit',
+ {realestatelocnum => $self->realestatelocnum}
+ );
+}
+
+
+=head1 SUBROUTINES
+
+=over 4
+
+=cut
+
+
+
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::record>, L<FS::realestate_unit>, L<FS::svc_realestate>
+
+=cut
+
+1;
diff --git a/FS/FS/realestate_unit.pm b/FS/FS/realestate_unit.pm
new file mode 100644
index 000000000..d1d1f7fda
--- /dev/null
+++ b/FS/FS/realestate_unit.pm
@@ -0,0 +1,163 @@
+package FS::realestate_unit;
+use strict;
+use warnings;
+use Carp qw(croak);
+
+use base 'FS::Record';
+use FS::Record qw(qsearch qsearchs);
+
+=head1 NAME
+
+FS::realestate_unit - Object representing a realestate_unit record
+
+=head1 SYNOPSIS
+
+ use FS::realestate_unit;
+
+ $record = new FS:realestate_unit \%values;
+ $record = new FS::realestate_unit {
+ realestatelocnum => 42,
+ agentnum => 1,
+ unit_title => 'Ste 404',
+ };
+
+ $error = $record->insert;
+ $error = $new_rec->replace($record)
+ $error = $record->check;
+
+ $location = $record->location;
+
+=head1 DESCRIPTION
+
+An FS::realestate_unit object represents an invoicable unit of real estate.
+Object may represent a single property, such as a rental house. It may also
+represent a group of properties sharing a common address or identifier, such
+as a shopping mall, apartment complex, or office building, concert hall.
+
+A FS::realestate_unit object must be associated with a FS::realestate_location
+
+FS::realestate_unit inherits from FS::Record.
+
+The following fields are currently supported:
+
+=over 4
+
+=item realestatenum
+
+=item realestatelocnum
+
+=item agentnum
+
+=item unit_title
+
+=item disabled
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF (see L<FS::Record>)
+
+=cut
+
+sub table {'realestate_unit';}
+
+=item insert (see L<FS::Record>)
+
+=item delete
+
+ FS::realestate_unit records should never be deleted, only disabled
+
+=cut
+
+sub delete {
+ # Once this record has been associated with a customer in any way, it
+ # should not be deleted. todo perhaps, add a is_deletable function that
+ # checks if the record has ever actually been used, and allows deletion
+ # if it hasn't. (entered in error, etc).
+ croak "FS::realestate_unit records should never be deleted";
+}
+
+
+=item replace OLD_RECORD (see L<FS::Record>)
+
+=item check (see L<FS::Record>)
+
+=item agent
+
+Returns the associated agent, if any, for this object
+
+=cut
+
+sub agent {
+ my $self = shift;
+ return undef unless $self->agentnum;
+ return qsearchs('agent', {agentnum => $self->agentnum} );
+}
+
+=item location
+
+ Return the associated FS::realestate_location object
+
+=cut
+
+sub location {
+ my $self = shift;
+ return $self->{location} if exists $self->{location};
+ return $self->{location} = qsearchs(
+ 'realestate_location',
+ {realestatelocnum => $self->realestatelocnum}
+ );
+}
+
+=back
+
+=item custnum
+
+Pull the assigned custnum for this unit, if provisioned
+
+=cut
+
+sub custnum {
+ my $self = shift;
+ return $self->{custnum}
+ if $self->{custnum};
+
+ # select cust_pkg.custnum
+ # from svc_realestate
+ # LEFT JOIN cust_svc ON svc_realestate.svcnum = cust_svc.svcnum
+ # LEFT JOIN cust_pkg ON cust_svc.pkgnum = cust_pkg.pkgnum
+ # WHERE svc_realestate.realestatenum = $realestatenum
+
+ my $row = qsearchs({
+ select => 'cust_pkg.custnum',
+ table => 'svc_realestate',
+ addl_from => 'LEFT JOIN cust_svc ON svc_realestate.svcnum = cust_svc.svcnum '
+ . 'LEFT JOIN cust_pkg ON cust_svc.pkgnum = cust_pkg.pkgnum ',
+ extra_sql => 'WHERE svc_realestate.realestatenum = '.$self->realestatenum,
+ });
+
+ return
+ unless $row && $row->custnum;
+
+ return $self->{custnum} = $row->custnum;
+}
+
+=head1 SUBROUTINES
+
+=over 4
+
+=cut
+
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::record>, L<FS::realestate_location>, L<FS::svc_realestate>
+
+=cut
+
+1;
diff --git a/FS/FS/svc_Common.pm b/FS/FS/svc_Common.pm
index f2456a56f..afd5db64f 100644
--- a/FS/FS/svc_Common.pm
+++ b/FS/FS/svc_Common.pm
@@ -122,6 +122,15 @@ sub virtual_fields {
=item label
+Returns a label to identify a record of this service.
+Label may be displayed on freeside screens, and within customer bills.
+
+For example, $obj->label may return:
+
+ - A provisioned phone number for svc_phone
+ - The mailing list name and e-mail address for svc_mailinglist
+ - The address of a rental property svc_realestate
+
svc_Common provides a fallback label subroutine that just returns the svcnum.
=cut
@@ -1586,4 +1595,3 @@ from the base documentation.
=cut
1;
-
diff --git a/FS/FS/svc_realestate.pm b/FS/FS/svc_realestate.pm
new file mode 100644
index 000000000..a7512eef8
--- /dev/null
+++ b/FS/FS/svc_realestate.pm
@@ -0,0 +1,172 @@
+package FS::svc_realestate;
+use base qw(FS::svc_Common);
+
+use strict;
+use warnings;
+use vars qw($conf);
+
+use FS::Record qw(qsearchs qsearch dbh);
+use Tie::IxHash;
+
+$FS::UID::callback{'FS::svc_realestate'} = sub {
+ $conf = new FS::Conf;
+};
+
+=head1 NAME
+
+FS::svc_realestate - Object methods for svc_realestate records
+
+=head1 SYNOPSIS
+
+ {...} TODO
+
+=head1 DESCRIPTION
+
+A FS::svc_realestate object represents a billable real estate trasnaction,
+such as renting a home or office.
+
+FS::svc_realestate inherits from FS::svc_Common. The following fields are
+currently supported:
+
+=over 4
+
+=item svcnum - primary key
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Instantiates a new svc_realestate object.
+
+=cut
+
+sub table_info {
+ tie my %fields, 'Tie::IxHash',
+ svcnum => 'Service',
+ realestatenum => {
+ type => 'select-realestate_unit',
+ label => 'Real estate unit',
+ };
+
+ {
+ name => 'Real estate',
+ name_plural => 'Real estate services',
+ longname_plural => 'Real estate services',
+ display_weight => 100,
+ cancel_weight => 100,
+ fields => \%fields,
+ };
+}
+
+sub table {'svc_realestate'}
+
+=item label
+
+Returns a label formatted as:
+ <location_title> <unit_title>
+
+=cut
+
+sub label {
+ my $self = shift;
+ my $unit = $self->realestate_unit;
+ my $location = $self->realestate_location;
+
+ return $location->location_title.' '.$unit->unit_title
+ if $unit && $location;
+
+ return $self->svcnum; # shouldn't happen
+}
+
+
+=item realestate_unit
+
+Returns associated L<FS::realestate_unit>
+
+=cut
+
+sub realestate_unit {
+ my $self = shift;
+
+ return $self->get('_realestate_unit')
+ if $self->get('_realestate_unit');
+
+ return unless $self->realestatenum;
+
+ my $realestate_unit = qsearchs(
+ 'realestate_unit',
+ {realestatenum => $self->realestatenum}
+ );
+
+ $self->set('_realestate_unit', $realestate_unit);
+ $realestate_unit;
+}
+
+=item realestate_location
+
+Returns associated L<FS::realestate_location>
+
+=cut
+
+sub realestate_location {
+ my $self = shift;
+
+ my $realestate_unit = $self->realestate_unit;
+ return unless $realestate_unit;
+
+ $realestate_unit->location;
+}
+
+=item cust_svc
+
+Returns associated L<FS::cust_svc>
+
+=cut
+
+sub cust_svc {
+ qsearchs('cust_svc', { 'svcnum' => $_[0]->svcnum } );
+}
+
+=item search_sql
+
+I have an unfounded suspicion this method serves no purpose in this context
+
+=cut
+
+# sub search_sql {die "search_sql called on FS::svc_realestate"}
+
+=item insert
+
+Adds this record to the database. If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database. If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record. If there is
+an error, returns the error, otherwise returns false. Called by the insert
+and replace methods.
+
+=back 4
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
diff --git a/FS/bin/freeside-cdr-aninetworks-import b/FS/bin/freeside-cdr-aninetworks-import
new file mode 100755
index 000000000..b5fc226a4
--- /dev/null
+++ b/FS/bin/freeside-cdr-aninetworks-import
@@ -0,0 +1,222 @@
+#!/usr/bin/perl
+
+use strict;
+use Getopt::Std;
+use Date::Format;
+use File::Temp 'tempdir';
+use Net::SFTP::Foreign::Compat;
+use FS::UID qw(adminsuidsetup datasrc dbh);
+use FS::cdr;
+use FS::cdr_batch;
+use FS::Record qw(qsearch qsearchs);
+use Date::Format 'time2str';
+use Date::Parse 'str2time';
+
+
+###
+# parse command line
+###
+
+use vars qw( $opt_d $opt_v $opt_c $opt_s $opt_e $opt_a );
+getopts('dvc:s:e:a');
+
+my ($user, $login, $password) = @ARGV;
+($user and $login and $password) or die &usage;
+
+my $dbh = adminsuidsetup $user;
+$FS::UID::AutoCommit = 0;
+
+# index already-downloaded batches
+my @previous = qsearch({
+ 'table' => 'cdr_batch',
+ 'hashref' => { 'cdrbatch' => {op=>'like', value=>'ani_networks%'} },
+ 'order_by' => 'ORDER BY cdrbatch DESC',
+});
+my %exists = map {$_->cdrbatch => 1} @previous;
+
+my $format = 'ani_networks';
+my $host = 'arkftp.aninetworks.com';
+
+###
+# get the file list
+###
+
+warn "Retrieving directory listing\n" if $opt_v;
+
+my $sftp = sftp();
+
+## get the current working dir
+my $cwd = $sftp->cwd;
+
+## switch to CDR dir
+$sftp->setcwd($cwd . '/CDR') or die "can't chdir to $cwd/CDR\n";
+
+my $ls = $sftp->ls('.', wanted => qr/^UYM.*.zip$/i, names_only =>1 );
+my @files = @$ls;
+
+warn scalar(@files)." CDR files found.\n" if $opt_v;
+# apply date range from last downloaded batch.
+if ( $opt_a ) {
+ my $most_recent = $previous[0];
+ if ($most_recent) {
+ if ($most_recent->cdrbatch =~ /^*Daily_(\d+)_/) {
+ my $date = $1;
+ warn "limiting to dates >= $date (from most recent batch)\n" if $opt_v;
+ @files = grep { /^*Daily_(\d+)_/ && $1 >= $date } @files;
+ }
+ }
+}
+
+# apply a start date if given
+if ( $opt_s ) {
+ # normalize date format
+ $opt_s = time2str('%Y%m%d', str2time($opt_s)) if $opt_s =~ /\D/;
+ warn "limiting to dates > $opt_s\n" if $opt_v;
+ @files= grep { /^*Daily_(\d+)_/ && $1 >= $opt_s } @files;
+}
+
+# apply a end date if given
+if ( $opt_e ) {
+ # normalize date format
+ $opt_e = time2str('%Y%m%d', str2time($opt_e)) if $opt_e =~ /\D/;
+ warn "limiting to dates < $opt_e\n" if $opt_v;
+ @files= grep { /^*Daily_(\d+)_/ && $1 < $opt_e } @files;
+}
+
+warn scalar(@files) ." files to be downloaded\n" if $opt_v;
+foreach my $file (@files) {
+
+ my $tmpdir = tempdir( CLEANUP => $opt_v );
+
+ warn "downloading $file to $tmpdir\n" if $opt_v;
+ $sftp = sftp();
+ $sftp->get($file, "$tmpdir/$file");
+
+ ## extract zip file
+ if(system ("unzip $tmpdir/$file -d $tmpdir") != 0) {
+ unlink "$tmpdir/$file";
+ my $error = "unzip of '$tmpdir/$file' failed\n";
+ if ( $opt_s ) {
+ warn $error;
+ next;
+ } else {
+ die $error;
+ }
+ }
+
+ warn "processing $file\n" if $opt_v;
+
+ my $batchname = "$format-$file";
+ if ($exists{$batchname}) {
+ warn "already imported $file\n";
+ next;
+ }
+
+ my $unzipped_file = $file;
+ $unzipped_file =~ s/.zip/.txt/i;
+
+ warn "going to import file $unzipped_file" if $opt_v;
+
+ my $import_options = {
+ 'file' => "$tmpdir/$unzipped_file",
+ 'format' => $format,
+ 'batch_namevalue' => $batchname,
+ 'empty_ok' => 1,
+ };
+ $import_options->{'cdrtypenum'} = $opt_c if $opt_c;
+
+ my $error = FS::cdr::batch_import($import_options);
+
+ if ( $error ) {
+ die "error processing $unzipped_file: $error\n";
+ }
+}
+warn "finished\n" if $opt_v;
+$dbh->commit;
+
+###
+# subs
+###
+
+sub usage {
+ "Usage: \n freeside-cdr-aninetworks-import [ options ] user login password
+ Options:
+ -v: be verbose
+ -d: enable FTP debugging (very noisy)
+ -c num: apply a cdrtypenum to the imported CDRs
+ -s date: start date
+ -e date: end date
+ -a: automatically choose start date from most recently downloaded batch
+
+";
+}
+
+sub sftp {
+
+ #reuse connections
+ return $sftp if $sftp && $sftp->cwd;
+
+ my %sftp = ( host => $host,
+ user => $login,
+ password => $password,
+ more => [-o => 'StrictHostKeyChecking no'],
+ );
+
+ $sftp = Net::SFTP::Foreign->new(%sftp);
+ $sftp->error and die "SFTP connection failed: ". $sftp->error;
+
+ $sftp;
+}
+
+=head1 NAME
+
+freeside-cdr-aninetworks-import - Download CDR files from a remote server via SFTP
+
+=head1 SYNOPSIS
+
+ freeside-cdr-aninetworks-import [ -v ] [ -d ] [ -a ] [ -c cdrtypenum ]
+ [ -s startdate ] [ -e enddate ] user sftpuser sftppassword
+
+=head1 DESCRIPTION
+
+Command line tool to download CDR files from a remote server via SFTP
+and then import them into the database.
+
+-v: be verbose
+
+-d: enable sftp debugging (very noisy)
+
+-a: automatically choose start date from most recently downloaded batch
+
+-c: cdrtypenum to set, defaults to none
+
+-s: if specified, sets a startdate. startdate starts at 00:00:00
+
+-e: if specified, sets a enddate. enddate starts at 00:00:00 so if you wish to include enddate must add one more day.
+
+user: freeside username
+
+sftpuser: sftp user for arkftp.aninetworks.com
+
+sftppassword: password for sftp user
+
+=head1 EXAMPLES
+
+freeside-cdr-aninetworks-import -a <freeside user> <sftp login> <sftp password>
+will get all cdr files starting from the day of the last day processed.
+
+freeside-cdr-aninetworks-import -s 20180120 -e 20180121 <freeside user> <sftp login> <sftp password>
+will get all cdr files from 01/20/2018
+
+freeside-cdr-aninetworks-import -v -s $(date --date="-1 day" +\%Y\%m\%d) -e $(date +\%Y\%m\%d) <freeside user> <sftp login> <sftp password>
+will get all cdr files from yesterday
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cdr>
+
+=cut
+
+1; \ No newline at end of file
diff --git a/FS/bin/freeside-cdr-freeswitch b/FS/bin/freeside-cdr-freeswitch
index 7f09578d4..3c18ef2a6 100644
--- a/FS/bin/freeside-cdr-freeswitch
+++ b/FS/bin/freeside-cdr-freeswitch
@@ -5,11 +5,12 @@ use Date::Parse 'str2time';
use FS::cdr::Import;
FS::cdr::Import->dbi_import(
- 'dbd' => 'Pg',
- 'database' => 'fusionpbx',
- 'table' => 'v_xml_cdr',
- 'primary_key' => 'uuid',
- 'column_map' => { #freeside => fusionpbx
+ 'dbd' => 'Pg',
+ 'database' => 'fusionpbx',
+ 'table' => 'v_xml_cdr',
+ 'status_column' => 'freesidestatus',
+ 'primary_key' => 'uuid',
+ 'column_map' => { #freeside => fusionpbx
#'cdrid' => 'uuid', #Primary key
#'' => 'CALL_SESSION_ID', # Call Session Id (unique per call session – GUID)
'uniqueid' => 'uuid', #
diff --git a/FS/t/realestate_location.t b/FS/t/realestate_location.t
new file mode 100644
index 000000000..ecb1d8be9
--- /dev/null
+++ b/FS/t/realestate_location.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::realestate_location;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/realestate_unit.t b/FS/t/realestate_unit.t
new file mode 100644
index 000000000..bbecc1a4c
--- /dev/null
+++ b/FS/t/realestate_unit.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::realestate_unit;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/svc_realestate.t b/FS/t/svc_realestate.t
new file mode 100644
index 000000000..4145d8b52
--- /dev/null
+++ b/FS/t/svc_realestate.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::svc_realestate;
+$loaded=1;
+print "ok 1\n";