diff options
author | Ivan Kohler <ivan@freeside.biz> | 2018-02-09 19:10:00 -0800 |
---|---|---|
committer | Ivan Kohler <ivan@freeside.biz> | 2018-02-09 19:10:00 -0800 |
commit | d45dd4a826f314fb5459747590d3e11cd80c211f (patch) | |
tree | c1dd2edd4bc42b12cc9a995e95dd7fb630da925e /FS | |
parent | 4b67c9f8cfc9f944b7758e7e69ac1f9f188ffa47 (diff) | |
parent | 15d596e3090f3bde642917b56563736cd1ee2e90 (diff) |
Merge branch 'master' of git.freeside.biz:/home/git/freeside
Diffstat (limited to 'FS')
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"; |