svc_hardware: better error messages for bad hw_addr when not validating as a MAC...
[freeside.git] / FS / FS / h_cust_pkg.pm
1 package FS::h_cust_pkg;
2
3 use strict;
4 use vars qw( @ISA );
5 use FS::h_Common;
6 use FS::cust_pkg;
7
8 @ISA = qw( FS::h_Common FS::cust_pkg );
9
10 sub table { 'h_cust_pkg' };
11
12 =head1 NAME
13
14 FS::h_cust_pkg - Historical record of customer package changes
15
16 =head1 SYNOPSIS
17
18 =head1 DESCRIPTION
19
20 An FS::h_cust_pkg object represents historical changes to packages.
21 FS::h_cust_pkg inherits from FS::h_Common and FS::cust_pkg.
22
23 =head1 CLASS METHODS
24
25 =over 4
26
27 =item search HASHREF
28
29 Like L<FS::cust_pkg::search>, but adapted for searching historical records.
30 Takes the additional parameter "date", which is the timestamp to perform 
31 the search "as of" (i.e. search the most recent insert or replace_new record
32 for each pkgnum that is not later than that date).
33
34 =cut
35
36 sub search {
37   my ($class, $params) = @_;
38   my $date = delete $params->{'date'};
39   $date =~ /^\d*$/ or die "invalid search date '$date'\n";
40
41   my $query = FS::cust_pkg->search($params);
42
43   # allow multiple status criteria
44   # this might be useful in the base cust_pkg search, but I haven't 
45   # tested it there yet
46   my $status = delete $params->{'status'};
47   if( $status ) {
48     my @status_where;
49     foreach ( split(',', $status) ) {
50       if ( /^active$/ ) {
51         push @status_where, $class->active_sql();
52       } elsif ( /^not[ _]yet[ _]billed$/ ) {
53         push @status_where, $class->not_yet_billed_sql();
54       } elsif ( /^(one-time charge|inactive)$/ ) {
55         push @status_where, $class->inactive_sql();
56       } elsif ( /^suspended$/ ) {
57         push @status_where, $class->suspended_sql();
58       } elsif ( /^cancell?ed$/ ) {
59         push @status_where, $class->cancelled_sql();
60       }
61     }
62     if ( @status_where ) {
63       $query->{'extra_sql'}   .= ' AND ('.join(' OR ', @status_where).')';
64       $query->{'count_query'} .= ' AND ('.join(' OR ', @status_where).')';
65     }
66   }
67
68   # make some adjustments
69   $query->{'table'} = 'h_cust_pkg';
70   foreach (qw(select addl_from extra_sql count_query order_by)) {
71     $query->{$_} =~ s/cust_pkg\b/h_cust_pkg/g;
72     $query->{$_} =~ s/cust_main\b/h_cust_main/g;
73   }
74   
75   my $and_where = " AND h_cust_pkg.historynum = 
76   (SELECT historynum FROM h_cust_pkg AS mostrecent
77   WHERE mostrecent.pkgnum = h_cust_pkg.pkgnum 
78   AND mostrecent.history_date <= $date
79   AND mostrecent.history_action IN ('insert', 'replace_new')
80   ORDER BY history_date DESC,historynum DESC LIMIT 1
81   ) AND h_cust_main.historynum =
82   (SELECT historynum FROM h_cust_main AS mostrecent
83   WHERE mostrecent.custnum = h_cust_main.custnum
84   AND mostrecent.history_date <= h_cust_pkg.history_date
85   AND mostrecent.history_action IN ('insert', 'replace_new')
86   ORDER BY history_date DESC,historynum DESC LIMIT 1
87   )";
88
89   $query->{'extra_sql'} .= $and_where;
90   $query->{'count_query'} .= $and_where;
91
92   $query;
93 }
94
95 =item churn_fromwhere_sql STATUS, START, END
96
97 Returns SQL fragments to do queries related to "package churn". STATUS
98 is one of "active", "setup", "cancel", "susp", or "unsusp". These do NOT
99 correspond directly to package statuses. START and END define a date range.
100
101 - active: limit to packages that were active on START. END is ignored.
102 - setup: limit to packages that were set up between START and END, except
103 those created by package changes.
104 - cancel: limit to packages that were canceled between START and END, except
105 those changed into other packages.
106 - susp: limit to packages that were suspended between START and END.
107 - unsusp: limit to packages that were unsuspended between START and END.
108
109 The logic of these may change in the future, especially with respect to 
110 package changes. Watch this space.
111
112 Returns a list of:
113 - a fragment usable as a FROM clause (without the keyword FROM), in which
114   the package table is named or aliased to 'cust_pkg'
115 - one or more conditions to include in the WHERE clause
116
117 =cut
118
119 sub churn_fromwhere_sql {
120   my ($self, $status, $speriod, $eperiod) = @_;
121
122   my ($from, @where);
123   if ( $status eq 'active' ) {
124     # for all packages that were setup before $speriod, find the pkgnum
125     # and the most recent update of the package before $speriod
126     my $setup_before = "SELECT DISTINCT ON (pkgnum) pkgnum, historynum
127       FROM h_cust_pkg
128       WHERE setup < $speriod
129         AND history_date < $speriod
130         AND history_action IN('insert', 'replace_new')
131       ORDER BY pkgnum ASC, history_date DESC";
132     # for each of these, exclude if the package was suspended or canceled
133     # in the most recent update before $speriod
134     $from = "h_cust_pkg AS cust_pkg
135       JOIN ($setup_before) AS setup_before USING (historynum)";
136     @where = ( 'susp IS NULL', 'cancel IS NULL' );
137   } elsif ( $status eq 'setup' ) {
138     # the simple case, because packages should only get set up once
139     # (but exclude those that were created due to a package change)
140     # XXX or should we include if they were created by a pkgpart change?
141     $from = "cust_pkg";
142     @where = (
143       "cust_pkg.setup >= $speriod",
144       "cust_pkg.setup < $eperiod",
145       "cust_pkg.change_pkgnum IS NULL"
146     );
147   } elsif ( $status eq 'cancel' ) {
148     # also simple, because packages should only be canceled once
149     # (exclude those that were canceled due to a package change)
150     $from = "cust_pkg";
151     @where = (
152       "cust_pkg.cancel >= $speriod",
153       "cust_pkg.cancel < $eperiod",
154       "NOT EXISTS(SELECT 1 FROM cust_pkg AS changed_to_pkg ".
155         "WHERE cust_pkg.pkgnum = changed_to_pkg.change_pkgnum)",
156     );
157   } elsif ( $status eq 'susp' ) {
158     # more complicated
159     # find packages that were changed from susp = null to susp != null
160     my $susp_during = $self->sql_diff($speriod, $eperiod) .
161       ' WHERE old.susp IS NULL AND new.susp IS NOT NULL';
162     $from = "h_cust_pkg AS cust_pkg
163       JOIN ($susp_during) AS susp_during
164         ON (susp_during.new_historynum = cust_pkg.historynum)";
165     @where = ( 'cust_pkg.cancel IS NULL' );
166   } elsif ( $status eq 'unsusp' ) {
167     # similar to 'susp'
168     my $unsusp_during = $self->sql_diff($speriod, $eperiod) .
169       ' WHERE old.susp IS NOT NULL AND new.susp IS NULL';
170     $from = "h_cust_pkg AS cust_pkg
171       JOIN ($unsusp_during) AS unsusp_during
172         ON (unsusp_during.new_historynum = cust_pkg.historynum)";
173     @where = ( 'cust_pkg.cancel IS NULL' );
174   } else {
175     die "'$status' makes no sense";
176   }
177   return ($from, @where);
178 }
179
180 =head1 as_of_sql DATE
181
182 Returns a qsearch hash for the instantaneous state of the cust_pkg table 
183 on DATE.
184
185 Currently accepts no restrictions; use it in a subquery if you want to 
186 limit or sort the output. (Restricting within the query is problematic.)
187
188 =cut
189
190 sub as_of_sql {
191   my $class = shift;
192   my $date = shift;
193   "SELECT DISTINCT ON (pkgnum) *
194     FROM h_cust_pkg
195     WHERE history_date < $date
196       AND history_action IN('insert', 'replace_new')
197     ORDER BY pkgnum ASC, history_date DESC"
198 }
199
200 =item status_query DATE
201
202 Returns a statement for determining the status of packages on a particular 
203 past date.
204
205 =cut
206
207 sub status_as_of_sql {
208   my $class = shift;
209   my $date = shift;
210
211   my @select = (
212     'h_cust_pkg.*',
213     FS::cust_pkg->active_sql() . ' AS is_active',
214     FS::cust_pkg->suspended_sql() . ' AS is_suspended',
215     FS::cust_pkg->cancelled_sql() . ' AS is_cancelled',
216   );
217   # foo_sql queries reference 'cust_pkg' in field names
218   foreach(@select) {
219     s/\bcust_pkg\b/h_cust_pkg/g;
220   }
221
222   return "SELECT DISTINCT ON(pkgnum) ".join(',', @select).
223          " FROM h_cust_pkg".
224          " WHERE history_date < $date AND history_action IN('insert','replace_new')".
225          " ORDER BY pkgnum ASC, history_date DESC";
226 }
227
228 =head1 BUGS
229
230 churn_fromwhere_sql and as_of_sql fail on MySQL.
231
232 =head1 SEE ALSO
233
234 L<FS::cust_pkg>,  L<FS::h_Common>, L<FS::Record>, schema.html from the base
235 documentation.
236
237 =cut
238
239 1;
240