$conf $cdr_prerate %cdr_prerate_cdrtypenums
);
use Exporter;
+use List::Util qw(first min);
use Tie::IxHash;
use Date::Parse;
use Date::Format;
use Time::Local;
+use List::Util qw( first min );
+use Text::CSV_XS;
use FS::UID qw( dbh );
use FS::Conf;
use FS::Record qw( qsearch qsearchs );
=item lastdata - Last application data
+=item src_ip_addr - Source IP address (dotted quad, zero-filled)
+
+=item dst_ip_addr - Destination IP address (same)
+
=item startdate - Start of call (UNIX-style integer timestamp)
=item answerdate - Answer time of call (UNIX-style integer timestamp)
=item svcnum - Link to customer service (see L<FS::cust_svc>)
-=item freesidestatus - NULL, processing-tiered, rated, done
+=item freesidestatus - NULL, processing-tiered, rated, done, skipped, no-charge, failed
=item freesiderewritestatus - NULL, done, skipped
'dstchannel' => 'Destination channel',
#'lastapp' => '',
#'lastdata' => '',
+ 'src_ip_addr' => 'Source IP',
+ 'dst_ip_addr' => 'Dest. IP',
'startdate' => 'Start date',
'answerdate' => 'Answer date',
'enddate' => 'End date',
$self->billsec( $self->enddate - $self->answerdate );
}
+ if ( ! $self->enddate && $self->startdate && $self->duration ) {
+ $self->enddate( $self->startdate + $self->duration );
+ }
+
$self->set_charged_party;
#check the foreign keys even?
Sets the status to the provided string. If there is an error, returns the
error, otherwise returns false.
+If status is being changed from 'rated' to some other status, also removes
+any usage allocations to this CDR.
+
=cut
sub set_status {
my($self, $status) = @_;
+ my $old_status = $self->freesidestatus;
$self->freesidestatus($status);
- $self->replace;
+ my $error = $self->replace;
+ if ( $old_status eq 'rated' and $status ne 'done' ) {
+ # deallocate any usage
+ foreach (qsearch('cdr_cust_pkg_usage', {acctid => $self->acctid})) {
+ my $cust_pkg_usage = $_->cust_pkg_usage;
+ $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $_->minutes);
+ $error ||= $cust_pkg_usage->replace || $_->delete;
+ }
+ }
+ $error;
}
=item set_status_and_rated_price STATUS RATED_PRICE [ SVCNUM [ OPTION => VALUE ... ] ]
Sets the status and rated price.
-Available options are: inbound, rated_seconds, rated_minutes, rated_classnum, rated_ratename
+Available options are: inbound, rated_pretty_dst, rated_regionname,
+rated_seconds, rated_minutes, rated_granularity, rated_ratedetailnum,
+rated_classnum, rated_ratename.
If there is an error, returns the error, otherwise returns false.
}
}
+=item parse_number [ OPTION => VALUE ... ]
+
+Returns two scalars, the countrycode and the rest of the number.
+
+Options are passed as name-value pairs. Currently available options are:
+
+=over 4
+
+=item column
+
+The column containing the number to be parsed. Defaults to dst.
+
+=item international_prefix
+
+The digits for international dialing. Defaults to '011' The value '+' is
+always recognized.
+
+=item domestic_prefix
+
+The digits for domestic long distance dialing. Defaults to '1'
+
+=back
+
+=cut
+
+sub parse_number {
+ my ($self, %options) = @_;
+
+ my $field = $options{column} || 'dst';
+ my $intl = $options{international_prefix} || '011';
+ my $countrycode = '';
+ my $number = $self->$field();
+
+ my $to_or_from = 'concerning';
+ $to_or_from = 'from' if $field eq 'src';
+ $to_or_from = 'to' if $field eq 'dst';
+ warn "parsing call $to_or_from $number\n" if $DEBUG;
+
+ #remove non-phone# stuff and whitespace
+ $number =~ s/\s//g;
+# my $proto = '';
+# $dest =~ s/^(\w+):// and $proto = $1; #sip:
+# my $siphost = '';
+# $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
+
+ if ( $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
+ || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
+ )
+ {
+
+ my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
+ #first look for 1 digit country code
+ if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
+ $countrycode = $one;
+ $number = $u1.$u2.$rest;
+ } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
+ $countrycode = $two;
+ $number = $u2.$rest;
+ } else { #3 digit country code
+ $countrycode = $three;
+ $number = $rest;
+ }
+
+ } else {
+ my $domestic_prefix =
+ exists($options{domestic_prefix}) ? $options{domestic_prefix} : '';
+ $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
+ $number =~ s/^$countrycode//;# if length($number) > 10;
+ }
+
+ return($countrycode, $number);
+
+}
+
=item rate [ OPTION => VALUE ... ]
Rates this CDR according and sets the status to 'rated'.
rated minutes of this CDR.
region_group_included_minutes_hashref is required for prefix price plans which
-have included minues (otehrwise unused/ignored). It should be set to an empty
+have included minues (otherwise unused/ignored). It should be set to an empty
hashref at the start of a month's rating and then preserved across CDRs.
=cut
sub rate_prefix {
my( $self, %opt ) = @_;
my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified";
+ my $cust_pkg = $opt{'cust_pkg'};
my $da_rewrote = 0;
# this will result in those CDRs being marked as done... is that
);
if ( $reason ) {
warn "not charging for CDR ($reason)\n" if $DEBUG;
- return $self->set_status_and_rated_price( 'rated',
+ return $self->set_status_and_rated_price( 'skipped',
0,
$opt{'svcnum'},
);
}
+ if ( $part_pkg->option_cacheable('skip_same_customer')
+ and ! $self->is_tollfree ) {
+ my ($dst_countrycode, $dst_number) = $self->parse_number(
+ column => 'dst',
+ international_prefix => $part_pkg->option_cacheable('international_prefix'),
+ domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+ );
+ my $dst_same_cust = FS::Record->scalar_sql(
+ 'SELECT COUNT(svc_phone.svcnum) AS count '.
+ 'FROM cust_pkg ' .
+ 'JOIN cust_svc USING (pkgnum) ' .
+ 'JOIN svc_phone USING (svcnum) ' .
+ 'WHERE svc_phone.countrycode = ' . dbh->quote($dst_countrycode) .
+ ' AND svc_phone.phonenum = ' . dbh->quote($dst_number) .
+ ' AND cust_pkg.custnum = ' . $cust_pkg->custnum,
+ );
+ if ( $dst_same_cust > 0 ) {
+ warn "not charging for CDR (same source and destination customer)\n" if $DEBUG;
+ return $self->set_status_and_rated_price( 'skipped',
+ 0,
+ $opt{'svcnum'},
+ );
+ }
+ }
+
+
+
###
# look up rate details based on called station id
# (or calling station id for toll free calls)
###
- my( $to_or_from, $number );
+ my( $to_or_from, $column );
if ( $self->is_tollfree && ! $part_pkg->option_cacheable('disable_tollfree') )
{ #tollfree call
$to_or_from = 'from';
- $number = $self->src;
+ $column = 'src';
} else { #regular call
$to_or_from = 'to';
- $number = $self->dst;
+ $column = 'dst';
}
- warn "parsing call $to_or_from $number\n" if $DEBUG;
-
- #remove non-phone# stuff and whitespace
- $number =~ s/\s//g;
-# my $proto = '';
-# $dest =~ s/^(\w+):// and $proto = $1; #sip:
-# my $siphost = '';
-# $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
-
#determine the country code
- my $intl = $part_pkg->option_cacheable('international_prefix') || '011';
- my $countrycode = '';
- if ( $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
- || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
- )
- {
-
- my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
- #first look for 1 digit country code
- if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
- $countrycode = $one;
- $number = $u1.$u2.$rest;
- } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
- $countrycode = $two;
- $number = $u2.$rest;
- } else { #3 digit country code
- $countrycode = $three;
- $number = $rest;
- }
-
- } else {
- my $domestic_prefix = $part_pkg->option_cacheable('domestic_prefix');
- $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
- $number =~ s/^$countrycode//;# if length($number) > 10;
- }
+ my ($countrycode, $number) = $self->parse_number(
+ column => $column,
+ international_prefix => $part_pkg->option_cacheable('international_prefix'),
+ domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+ );
warn "rating call $to_or_from +$countrycode $number\n" if $DEBUG;
my $pretty_dst = "+$countrycode $number";
# -disregard private or unknown numbers
# -there is exactly one record in rate_prefix for a given NPANXX
# -default to interstate if we can't find one or both of the prefixes
- my $dstprefix = $self->dst;
+ my (undef, $dstprefix) = $self->parse_number(
+ column => 'dst',
+ international_prefix => $part_pkg->option_cacheable('international_prefix'),
+ domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+ );
$dstprefix =~ /^(\d{6})/;
$dstprefix = qsearchs('rate_prefix', { 'countrycode' => '1',
'npa' => $1,
}) || '';
- my $srcprefix = $self->src;
+ my (undef, $srcprefix) = $self->parse_number(
+ column => 'src',
+ international_prefix => $part_pkg->option_cacheable('international_prefix'),
+ domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+ );
$srcprefix =~ /^(\d{6})/;
$srcprefix = qsearchs('rate_prefix', { 'countrycode' => '1',
'npa' => $1,
if ( !exists($interval_cache{$regionnum}) ) {
my @intervals = (
sort { $a->stime <=> $b->stime }
- map { my $r = $_->rate_time; $r ? $r->intervals : () }
- $rate->rate_detail
+ map { $_->rate_time->intervals }
+ qsearch({ 'table' => 'rate_detail',
+ 'hashref' => { 'ratenum' => $rate->ratenum },
+ 'extra_sql' => 'AND ratetimenum IS NOT NULL',
+ })
);
$interval_cache{$regionnum} = \@intervals;
warn " cached ".scalar(@intervals)." interval(s)\n"
my $seconds_left = $part_pkg->option_cacheable('use_duration')
? $self->duration
: $self->billsec;
- # charge for the first (conn_sec) seconds
- my $seconds = min($seconds_left, $rate_detail->conn_sec);
- $seconds_left -= $seconds;
- $weektime += $seconds;
- my $charge = $rate_detail->conn_charge;
+
+ #no, do this later so it respects (group) included minutes
+ # # charge for the first (conn_sec) seconds
+ # my $seconds = min($seconds_left, $rate_detail->conn_sec);
+ # $seconds_left -= $seconds;
+ # $weektime += $seconds;
+ # my $charge = $rate_detail->conn_charge;
+ my $seconds = 0;
+ my $charge = 0;
+ my $connection_charged = 0;
my $etime;
while($seconds_left) {
$seconds_left -= $charge_sec;
- my $included_min = $opt{'region_group_included_min_hashref'} || {};
-
- $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
- unless exists $included_min->{$regionnum}{$ratetimenum};
-
my $granularity = $rate_detail->sec_granularity;
my $minutes;
$seconds += $charge_sec;
- my $region_group = ($part_pkg->option_cacheable('min_included') || 0) > 0;
+ if ( $rate_detail->min_included ) {
+ # the old, kind of deprecated way to do this
+ my $included_min = $opt{'region_group_included_min_hashref'} || {};
+
+ # by default, set the included minutes for this region/time to
+ # what's in the rate_detail
+ $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
+ unless exists $included_min->{$regionnum}{$ratetimenum};
+
+ # the way that doesn't work
+ #my $region_group = ($part_pkg->option_cacheable('min_included') || 0) > 0;
+
+ #${$opt{region_group_included_min}} -= $minutes
+ # if $region_group && $rate_detail->region_group;
- ${$opt{region_group_included_min}} -= $minutes
- if $region_group && $rate_detail->region_group;
+ if ( $included_min->{$regionnum}{$ratetimenum} > $minutes ) {
+ $charge_sec = 0;
+ $included_min->{$regionnum}{$ratetimenum} -= $minutes;
+ } else {
+ $charge_sec -= ($included_min->{$regionnum}{$ratetimenum} * 60);
+ $included_min->{$regionnum}{$ratetimenum} = 0;
+ }
+ } else {
+ # the new way!
+ my $applied_min = $cust_pkg->apply_usage(
+ 'cdr' => $self,
+ 'rate_detail' => $rate_detail,
+ 'minutes' => $minutes
+ );
+ # for now, usage pools deal only in whole minutes
+ $charge_sec -= $applied_min * 60;
+ }
+
+ if ( $charge_sec > 0 ) {
+
+ #NOW do connection charges here... right?
+ #my $conn_seconds = min($seconds_left, $rate_detail->conn_sec);
+ my $conn_seconds = 0;
+ unless ( $connection_charged++ ) { #only one connection charge
+ $conn_seconds = min($charge_sec, $rate_detail->conn_sec);
+ $seconds_left -= $conn_seconds;
+ $weektime += $conn_seconds;
+ $charge += $rate_detail->conn_charge;
+ }
- $included_min->{$regionnum}{$ratetimenum} -= $minutes;
- if (
- $included_min->{$regionnum}{$ratetimenum} <= 0
- && ( ${$opt{region_group_included_min}} <= 0
- || ! $rate_detail->region_group
- )
- )
- {
#should preserve (display?) this
- my $charge_min = 0 - $included_min->{$regionnum}{$ratetimenum};
- $included_min->{$regionnum}{$ratetimenum} = 0;
- $charge += ($rate_detail->min_charge * $charge_min); #still not rounded
-
- } elsif ( ${$opt{region_group_included_min}} > 0
- && $region_group
- && $rate_detail->region_group
- )
- {
- $included_min->{$regionnum}{$ratetimenum} = 0
+ my $charge_min = ( $charge_sec - $conn_seconds ) / 60;
+ $charge += ($rate_detail->min_charge * $charge_min) if $charge_min > 0; #still not rounded
+
}
# choose next rate_detail
sub rate_upstream_simple {
my( $self, %opt ) = @_;
- $self->set_status_and_rated_price( 'rated',
- sprintf('%.3f', $self->upstream_price),
- $opt{'svcnum'},
- );
+ $self->set_status_and_rated_price(
+ 'rated',
+ sprintf('%.3f', $self->upstream_price),
+ $opt{'svcnum'},
+ 'rated_classnum' => $self->calltypenum,
+ );
}
sub rate_single_price {
sprintf('%.4f', ( $part_pkg->option_cacheable('min_charge') * $charge_min )
+ 0.0000000001 ); #so 1.00005 rounds to 1.0001
- $self->set_status_and_rated_price( 'rated',
- $charge,
- $opt{'svcnum'},
- );
+ $self->set_status_and_rated_price(
+ 'rated',
+ $charge,
+ $opt{'svcnum'},
+ 'rated_granularity' => $granularity,
+ 'rated_seconds' => $seconds,
+ );
}
'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
#"Date,Time,Name,Called From,Destination,Duration,Price",
},
+ 'accountcode_simple' => {
+ 'name' => 'Simple with accountcode',
+ 'invoice_header' => "Date,Time,Called From,Account,Duration,Price",
+ },
'basic' => {
'name' => 'Basic',
'invoice_header' => "Date/Time,Called Number,Min/Sec,Price",
'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
},
'sum_duration' => {
- 'name' => 'Summary (one line per service, with duration)',
+ 'name' => 'Summary, one line per service',
'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
},
'sum_count' => {
- 'name' => 'Summary (one line per service, with count)',
+ 'name' => 'Number of calls, one line per service',
'invoice_header' => 'Caller,Rate,Messages,Price',
},
+ 'sum_duration_prefix' => {
+ 'name' => 'Summary, one line per destination prefix',
+ 'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
+ },
);
my %export_formats = ();
length($price) ? ($opt{money_char} . $price) : '';
};
+ my $src_sub = sub { $_[0]->clid || $_[0]->src };
+
%export_formats = (
'simple' => [
sub { time2str($date_format, shift->calldate_unix ) }, #DATE
sub { time2str($date_format, shift->calldate_unix ) }, #DATE
sub { time2str('%r', shift->calldate_unix ) }, #TIME
#'userfield', #USER
- 'src', #called from
+ $src_sub, #called from
'dst', #NUMBER_DIALED
$duration_sub, #DURATION
#sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
$price_sub,
],
+ 'accountcode_simple' => [
+ sub { time2str($date_format, shift->calldate_unix ) }, #DATE
+ sub { time2str('%r', shift->calldate_unix ) }, #TIME
+ $src_sub, #called from
+ 'accountcode', #NUMBER_DIALED
+ $duration_sub, #DURATION
+ $price_sub,
+ ],
'sum_duration' => [
# for summary formats, the CDR is a fictitious object containing the
# total billsec and the phone number of the service
- 'src',
+ $src_sub,
sub { my($cdr, %opt) = @_; $opt{ratename} },
sub { my($cdr, %opt) = @_; $opt{count} },
sub { my($cdr, %opt) = @_; int($opt{seconds}/60).'m' },
$price_sub,
],
'sum_count' => [
- 'src',
+ $src_sub,
sub { my($cdr, %opt) = @_; $opt{ratename} },
sub { my($cdr, %opt) = @_; $opt{count} },
$price_sub,
$price_sub,
],
);
- $export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} }, ];
+ $export_formats{'source_default'} = [ $src_sub, @{ $export_formats{'default'} }, ];
$export_formats{'accountcode_default'} =
[ @{ $export_formats{'default'} }[0,1],
'accountcode',
];
my @default = @{ $export_formats{'default'} };
$export_formats{'description_default'} =
- [ 'src', @default[0..2],
+ [ $src_sub, @default[0..2],
sub { my($cdr, %opt) = @_; $cdr->description },
@default[4,5] ];
#$opt{'money_char'} ||= $conf->config('money_char') || '$';
$opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
- eval "use Text::CSV_XS;";
- die $@ if $@;
my $csv = new Text::CSV_XS;
my @columns =
=cut
+# in the future, load this dynamically from detail_format classes
+
sub invoice_formats {
map { ($_ => $export_names{$_}->{'name'}) }
grep { $export_names{$_}->{'invoice_header'} }
sub clear_status {
my $self = shift;
+ my %opt = @_;
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
my $dbh = dbh;
if ( $cdr_prerate && $cdr_prerate_cdrtypenums{$self->cdrtypenum}
+ && $self->rated_ratedetailnum #avoid putting old CDRs back in "rated"
&& $self->freesidestatus eq 'done'
+ && ! $opt{'rerate'}
)
{ #special case
$self->freesidestatus('rated');
keys %cdr_info
},
+ 'format_asn_formats' =>
+ { map { $_ => $cdr_info{$_}->{'asn_format'}; }
+ keys %cdr_info
+ },
+
'format_row_callbacks' => { map { $_ => $cdr_info{$_}->{'row_callback'}; }
keys %cdr_info
},
}
+=item ip_addr_sql FIELD RANGE
+
+Returns an SQL condition to search for CDRs with an IP address
+within RANGE. FIELD is either 'src_ip_addr' or 'dst_ip_addr'. RANGE
+should be in the form "a.b.c.d-e.f.g.h' (dotted quads), where any of
+the leftmost octets of the second address can be omitted if they're
+the same as the first address.
+
+=cut
+
+sub ip_addr_sql {
+ my $class = shift;
+ my ($field, $range) = @_;
+ $range =~ /^[\d\.-]+$/ or die "bad ip address range '$range'";
+ my @r = split('-', $range);
+ my @saddr = split('\.', $r[0] || '');
+ my @eaddr = split('\.', $r[1] || '');
+ unshift @eaddr, (undef) x (4 - scalar @eaddr);
+ for(0..3) {
+ $eaddr[$_] = $saddr[$_] if !defined $eaddr[$_];
+ }
+ "$field >= '".sprintf('%03d.%03d.%03d.%03d', @saddr) . "' AND ".
+ "$field <= '".sprintf('%03d.%03d.%03d.%03d', @eaddr) . "'";
+}
+
=back
=head1 BUGS