2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
8 use FS::UID qw( dbh myconnect );
9 use FS::Maketext qw( emt );
10 use FS::Record qw( qsearch qsearchs );
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
24 FS::quotation - Object methods for quotation records
30 $record = new FS::quotation \%hash;
31 $record = new FS::quotation { 'column' => 'value' };
33 $error = $record->insert;
35 $error = $new_record->replace($old_record);
37 $error = $record->delete;
39 $error = $record->check;
43 An FS::quotation object represents a quotation. FS::quotation inherits from
44 FS::Record. The following fields are currently supported:
74 projected date when the quotation will be closed
78 projected confidence (expressed as integer) that quotation will close
88 Creates a new quotation. To add the quotation to the database, see L<"insert">.
90 Note that this stores the hash reference, not a distinct copy of the hash it
91 points to. You can ask the object for a copy with the I<hash> method.
95 sub table { 'quotation'; }
96 sub notice_name { 'Quotation'; }
97 sub template_conf { 'quotation_'; }
98 sub has_sections { 1; }
102 Adds this record to the database. If there is an error, returns the error,
103 otherwise returns false.
107 Delete this record from the database.
109 =item replace OLD_RECORD
111 Replaces the OLD_RECORD with this one in the database. If there is an error,
112 returns the error, otherwise returns false.
116 Checks all fields to make sure this is a valid quotation. If there is
117 an error, returns the error, otherwise returns false. Called by the insert
126 $self->ut_numbern('quotationnum')
127 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
128 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
129 || $self->ut_numbern('_date')
130 || $self->ut_enum('disabled', [ '', 'Y' ])
131 || $self->ut_numbern('usernum')
132 || $self->ut_numbern('close_date')
133 || $self->ut_numbern('confidence')
135 return $error if $error;
137 $self->_date(time) unless $self->_date;
139 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
141 return 'confidence percentage must be an integer between 1 and 100'
142 if length($self->confidence)
143 && ( ($self->confidence < 1) || ($self->confidence > 100) );
145 return 'prospectnum or custnum must be specified'
146 if ! $self->prospectnum
160 sub cust_bill_pkg { #actually quotation_pkg objects
161 shift->quotation_pkg(@_);
170 sprintf('%.2f', $self->_total('setup') + $self->_total('setup_tax'));
173 =item total_recur [ FREQ ]
179 #=item total_recur [ FREQ ]
180 #my $freq = @_ ? shift : '';
181 sprintf('%.2f', $self->_total('recur') + $self->_total('recur_tax'));
185 my( $self, $method ) = @_;
188 $total += $_->$method() for $self->quotation_pkg;
189 sprintf('%.2f', $total);
195 my $opt = shift || {};
196 if ($opt and !ref($opt)) {
197 die ref($self). '->email called with positional parameters';
200 my $conf = $self->conf;
202 my $from = delete $opt->{from};
204 # this is where we set the From: address
205 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
206 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
207 $self->SUPER::email( {
218 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
221 #my $cust_main = $self->cust_main;
222 #my $name = $cust_main->name;
223 #my $name_short = $cust_main->name_short;
224 #my $invoice_number = $self->invnum;
225 #my $invoice_date = $self->_date_pretty;
232 'Quotation-'. $self->quotationnum. '.pdf';
235 =item cust_or_prosect
239 sub cust_or_prospect {
241 $self->custnum ? $self->cust_main : $self->prospect_main;
244 =item cust_or_prospect_label_link
246 HTML links to either the customer or prospect.
248 Returns a list consisting of two elements. The first is a text label for the
249 link, and the second is the URL.
253 sub cust_or_prospect_label_link {
254 my( $self, $p ) = @_;
256 if ( my $custnum = $self->custnum ) {
257 my $display_custnum = $self->cust_main->display_custnum;
258 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
260 : ';show=quotations';
262 emt("View this customer (#[_1])",$display_custnum) =>
263 "${p}view/cust_main.cgi?custnum=$custnum$target"
265 } elsif ( my $prospectnum = $self->prospectnum ) {
267 emt("View this prospect (#[_1])",$prospectnum) =>
268 "${p}view/prospect_main.html?$prospectnum"
276 sub _items_sections {
279 my $escape = $opt{escape}; # the only one we care about
282 my %show; # package frequency => 1 if there's anything to display
283 my %subtotals = (); # package frequency => subtotal
284 my $prorate_total = 0;
285 foreach my $pkg ($self->quotation_pkg) {
287 my $part_pkg = $pkg->part_pkg;
289 my $recur_freq = $part_pkg->freq;
290 $show{$recur_freq} = 1 if $pkg->unitrecur > 0 or $pkg->recur_show_zero;
291 $show{0} = 1 if $pkg->unitsetup > 0 or $pkg->setup_show_zero;
292 ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
293 ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
295 #this is a shitty hack based on what's in part_pkg/ at the moment
296 # but its good enough for the 99% common case of preventing totals from
297 # displaying for prorate packages
299 if $part_pkg->plan =~ /^(prorate|torrus|agent$)/
300 || $part_pkg->option('recur_method') eq 'prorate'
301 || ( $part_pkg->option('sync_bill_date')
303 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
306 #possible improvement: keep track of flat vs. prorate totals to make the
307 # bottom range more accurate when mixing flat and prorate packages
310 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
313 my $no_recurring = 0;
314 foreach my $freq (keys %subtotals) {
316 #next if $subtotals{$freq} == 0;
317 next if !$show{$freq};
320 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
322 if ( $freq eq '0' ) {
323 if ( scalar(keys(%subtotals)) == 1 ) {
324 # there are no recurring packages
326 $desc = $self->mt('Charges');
328 $desc = $self->mt('Setup Charges');
331 $desc = $self->mt('Recurring Charges') . ' - ' .
332 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
336 'description' => &$escape($desc),
337 'sort_weight' => $weight,
339 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
343 unless ( $no_recurring ) {
345 $total += $_ for values %subtotals;
348 'category' => 'Total category', #required but what's it used for?
351 if ( $prorate_total ) {
355 'description' => 'First payment (depending on day of month)',
356 'subtotal' => [ $subtotals{0}, $total ],
363 'description' => 'First payment',
364 'subtotal' => $total,
370 return \@sections, [];
373 =item enable_previous
377 sub enable_previous { 0 }
379 =item convert_cust_main [ PARAMS ]
381 If this quotation already belongs to a customer, then returns that customer, as
382 an FS::cust_main object.
384 Otherwise, creates a new customer (FS::cust_main object and record, and
385 associated) based on this quotation's prospect, then orders this quotation's
386 packages as real packages for the customer.
388 If there is an error, returns an error message, otherwise, returns the
389 newly-created FS::cust_main object.
391 Accepts the same params as L</order>.
395 sub convert_cust_main {
397 my $params = shift || {};
399 my $cust_main = $self->cust_main;
400 return $cust_main if $cust_main; #already converted, don't again
402 my $oldAutoCommit = $FS::UID::AutoCommit;
403 local $FS::UID::AutoCommit = 0;
406 $cust_main = $self->prospect_main->convert_cust_main;
407 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
408 $dbh->rollback if $oldAutoCommit;
412 $self->prospectnum('');
413 $self->custnum( $cust_main->custnum );
414 my $error = $self->replace || $self->order(undef,$params);
416 $dbh->rollback if $oldAutoCommit;
420 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
426 =item order [ HASHREF ] [ PARAMS ]
428 This method is for use with quotations which are already associated with a customer.
430 Orders this quotation's packages as real packages for the customer.
432 If there is an error, returns an error message, otherwise returns false.
434 If HASHREF is passed, it will be filled with a hash mapping the
435 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
438 If PARAMS hashref is passed, the following params are accepted:
440 onhold - if true, suspends newly ordered packages
446 my $pkgnum_map = shift || {};
447 my $params = shift || {};
448 my $details_map = {};
450 tie my %all_cust_pkg, 'Tie::RefHash';
451 foreach my $quotation_pkg ($self->quotation_pkg) {
452 my $cust_pkg = FS::cust_pkg->new;
453 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
455 # details will be copied below, after package is ordered
456 $details_map->{ $quotation_pkg->quotationpkgnum } = [
457 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
460 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
461 $cust_pkg->set( $_, $quotation_pkg->get($_) );
464 # can now have two discounts each (setup and recur)
465 foreach my $pkg_discount ($quotation_pkg->quotation_pkg_discount) {
466 my $field = $pkg_discount->setuprecur . '_discountnum';
467 $cust_pkg->set($field, $pkg_discount->discountnum);
470 $all_cust_pkg{$cust_pkg} = []; # no services
473 local $SIG{HUP} = 'IGNORE';
474 local $SIG{INT} = 'IGNORE';
475 local $SIG{QUIT} = 'IGNORE';
476 local $SIG{TERM} = 'IGNORE';
477 local $SIG{TSTP} = 'IGNORE';
478 local $SIG{PIPE} = 'IGNORE';
480 my $oldAutoCommit = $FS::UID::AutoCommit;
481 local $FS::UID::AutoCommit = 0;
484 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
487 # copy details (copy_on_order filtering handled above)
488 foreach my $quotationpkgnum (keys %$details_map) {
489 next unless @{$details_map->{$quotationpkgnum}};
490 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
492 @{$details_map->{$quotationpkgnum}}
498 if ($$params{'onhold'}) {
499 foreach my $quotationpkgnum (keys %$pkgnum_map) {
501 $error = $pkgnum_map->{$quotationpkgnum}->suspend();
506 $dbh->rollback if $oldAutoCommit;
510 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
512 foreach my $quotationpkgnum (keys %$pkgnum_map) {
513 # convert the objects to just pkgnums
514 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
515 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
524 One-time charges, like FS::cust_main::charge()
528 #super false laziness w/cust_main::charge
531 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
532 my ( $pkg, $comment, $additional );
533 my ( $setuptax, $taxclass ); #internal taxes
534 my ( $taxproduct, $override ); #vendor (CCH) taxes
536 my $cust_pkg_ref = '';
537 my ( $bill_now, $invoice_terms ) = ( 0, '' );
539 my ( $discountnum, $discountnum_amount, $discountnum_percent ) = ( '','','' );
540 if ( ref( $_[0] ) ) {
541 $amount = $_[0]->{amount};
542 $setup_cost = $_[0]->{setup_cost};
543 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
544 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
545 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
546 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
547 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
548 : '$'. sprintf("%.2f",$amount);
549 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
550 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
551 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
552 $additional = $_[0]->{additional} || [];
553 $taxproduct = $_[0]->{taxproductnum};
554 $override = { '' => $_[0]->{tax_override} };
555 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
556 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
557 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
558 $locationnum = $_[0]->{locationnum};
559 $discountnum = $_->{setup_discountnum};
560 $discountnum_amount = $_->{setup_discountnum_amount};
561 $discountnum_percent = $_->{setup_discountnum_percent};
567 $pkg = @_ ? shift : 'One-time charge';
568 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
570 $taxclass = @_ ? shift : '';
574 local $SIG{HUP} = 'IGNORE';
575 local $SIG{INT} = 'IGNORE';
576 local $SIG{QUIT} = 'IGNORE';
577 local $SIG{TERM} = 'IGNORE';
578 local $SIG{TSTP} = 'IGNORE';
579 local $SIG{PIPE} = 'IGNORE';
581 my $oldAutoCommit = $FS::UID::AutoCommit;
582 local $FS::UID::AutoCommit = 0;
585 my $part_pkg = new FS::part_pkg ( {
587 'comment' => $comment,
591 'classnum' => ( $classnum ? $classnum : '' ),
592 'setuptax' => $setuptax,
593 'taxclass' => $taxclass,
594 'taxproductnum' => $taxproduct,
595 'setup_cost' => $setup_cost,
598 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
599 ( 0 .. @$additional - 1 )
601 'additional_count' => scalar(@$additional),
602 'setup_fee' => $amount,
605 my $error = $part_pkg->insert( options => \%options,
606 tax_overrides => $override,
609 $dbh->rollback if $oldAutoCommit;
613 my $pkgpart = $part_pkg->pkgpart;
616 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
618 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
619 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
620 $error = $type_pkgs->insert;
622 $dbh->rollback if $oldAutoCommit;
627 #except for DIFF, eveything above is idential to cust_main version
628 #but below is our own thing pretty much (adding a quotation package instead
629 # of ordering a customer package, no "bill now")
631 my $quotation_pkg = new FS::quotation_pkg ( {
632 'quotationnum' => $self->quotationnum,
633 'pkgpart' => $pkgpart,
634 'quantity' => $quantity,
635 #'start_date' => $start_date,
636 #'no_auto' => $no_auto,
637 'locationnum' => $locationnum,
638 'setup_discountnum' => $discountnum,
639 'setup_discountnum_amount' => $discountnum_amount,
640 'setup_discountnum_percent' => $discountnum_percent,
643 $error = $quotation_pkg->insert;
645 $dbh->rollback if $oldAutoCommit;
647 #} elsif ( $cust_pkg_ref ) {
648 # ${$cust_pkg_ref} = $cust_pkg;
651 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
658 Disables this quotation (sets disabled to Y, which hides the quotation on
659 prospects and customers).
661 If there is an error, returns an error message, otherwise returns false.
667 $self->disabled('Y');
673 Enables this quotation.
675 If there is an error, returns an error message, otherwise returns false.
687 Calculates current prices for all items on this quotation, including
688 discounts and taxes, and updates the quotation_pkg records accordingly.
694 my $conf = FS::Conf->new;
696 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
698 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
700 my @return_bill = ([]);
703 ###### BEGIN TRANSACTION ######
707 my $temp_dbh = myconnect();
708 local $FS::UID::dbh = $temp_dbh;
709 local $FS::UID::AutoCommit = 0;
711 my $fake_self = FS::quotation->new({ $self->hash });
713 # if this is a prospect, make them into a customer for now
714 # XXX prospects currently can't have service locations
715 my $cust_or_prospect = $self->cust_or_prospect;
717 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
718 $cust_main = $cust_or_prospect->convert_cust_main;
719 unless ( ref($cust_main) ) {
721 die "$cust_main (simulating customer signup)\n";
723 $fake_self->set('prospectnum', '');
724 $fake_self->set('custnum', $cust_main->custnum);
726 $cust_main = $cust_or_prospect;
730 local($FS::cust_pkg::disable_start_on_hold) = 1;
731 $error = $fake_self->order(\%pkgnum_of);
734 die "$error (simulating package order)\n";
737 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
739 # simulate the first bill
742 'pkg_list' => \@new_pkgs,
743 'time' => time, # an option to adjust this?
744 'return_bill' => $return_bill[0],
745 'no_usage_reset' => 1,
747 $error = $cust_main->bill(%bill_opt);
750 die "$error (simulating initial billing)\n" if $error;
753 # pick dates for future bills
755 foreach (@new_pkgs) {
756 my $bill = $_->get('bill');
758 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
762 foreach my $next_bill (keys %next_bill_pkgs) {
763 $bill_opt{'time'} = $next_bill;
764 $bill_opt{'return_bill'} = $return_bill[$i] = [];
765 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
766 $error = $cust_main->bill(%bill_opt);
769 die "$error (simulating recurring billing cycle $i)\n";
777 ###### END TRANSACTION ######
778 my %quotationpkgnum_of = reverse %pkgnum_of;
781 warn "pkgnums:\n".Dumper(\%pkgnum_of);
782 warn Dumper(\@return_bill);
785 # Careful: none of the foreign keys in here are correct outside the sandbox.
786 # We have a translation table for pkgnums; all others are total lies.
788 my %quotation_pkg; # quotationpkgnum => quotation_pkg
789 foreach my $qp ($self->quotation_pkg) {
790 $quotation_pkg{$qp->quotationpkgnum} = $qp;
791 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
792 $qp->set('freq', '');
793 # flush old tax records
794 foreach ($qp->quotation_pkg_tax) {
796 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
801 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
802 my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
804 for (my $i = 0; $i < scalar(@return_bill); $i++) {
805 my $this_bill = $return_bill[$i]->[0];
807 warn "$me billing cycle $i produced no invoice\n";
813 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
814 my $pkgnum = $cust_bill_pkg->pkgnum;
815 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
817 # taxes/fees; come back to it
818 push @nonpkg_lines, $cust_bill_pkg;
821 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
822 my $qp = $quotation_pkg{$quotationpkgnum};
824 # XXX supplemental packages could do this (they have separate pkgnums)
825 # handle that special case at some point
826 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
830 # then this is the first (setup) invoice
831 $qp->set('start_date', $cust_bill_pkg->sdate);
832 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
833 # pkgpart_override is a possibility
835 # recurring invoice (should be only one of these per package, though
836 # it may have multiple lineitems with the same pkgnum)
837 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
841 if ( $cust_bill_pkg->get('discounts') ) {
842 # discount records are generated as (setup, recur).
843 # well, not always, sometimes it's just (recur), but fixing this
844 # is horribly invasive.
845 my $discount = $cust_bill_pkg->get('discounts')->[0];
848 # find the quotation_pkg_discount record for this billing pass...
849 my $setuprecur = $i ? 'recur' : 'setup';
850 my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
851 ||= qsearchs('quotation_pkg_discount', {
852 'quotationpkgnum' => $quotationpkgnum,
853 'setuprecur' => $setuprecur,
856 if (!$qpd) { #can't happen
857 warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
860 $qpd->set('amount', $discount->amount);
863 } # end of discount stuff
868 foreach my $cust_bill_pkg (@nonpkg_lines) {
870 my $itemdesc = $cust_bill_pkg->itemdesc;
872 if ($cust_bill_pkg->feepart) {
873 warn "$me simulated bill included a non-package fee (feepart ".
874 $cust_bill_pkg->feepart.")\n";
877 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
878 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
880 # breadth-first unrolled recursion:
881 # take each tax link and any tax-on-tax descendants, and merge them
882 # into a single quotation_pkg_tax record for each pkgnum/taxname
884 while (my $tax_link = shift @$links) {
885 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
886 or die "$me unable to resolve tax link\n";
887 if ($target->pkgnum) {
888 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
889 # create this if there isn't one yet
890 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
891 FS::quotation_pkg_tax->new({
892 quotationpkgnum => $quotationpkgnum,
893 itemdesc => $itemdesc,
897 if ( $i == 0 ) { # first invoice
898 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
899 } else { # subsequent invoices
900 # this isn't perfectly accurate, but that's why it's an estimate
901 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
902 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
903 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
905 } elsif ($target->feepart) {
906 # do nothing; we already warned for the fee itself
908 # tax on tax: the tax target is another tax item.
909 # since this is an estimate, I'm just going to assign it to the
910 # first of the underlying packages. (RT#5243 is why we can't have
912 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
913 if ($sublinks and $sublinks->[0]) {
914 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
915 push @$links, $tax_link; #try again
917 warn "$me unable to assign tax on tax; ignoring\n";
920 } # while my $tax_link
922 } # foreach my $cust_bill_pkg
924 foreach my $quotation_pkg (values %quotation_pkg) {
925 $error = $quotation_pkg->replace;
926 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
929 foreach (values %quotation_pkg_discount) {
930 # { setup => one, recur => another }
931 foreach my $quotation_pkg_discount (values %$_) {
932 $error = $quotation_pkg_discount->replace;
933 return "$error (recording estimated discount)"
937 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
938 $error = $quotation_pkg_tax->insert;
939 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
952 =item search_sql_where HASHREF
954 Class method which returns an SQL WHERE fragment to search for parameters
955 specified in HASHREF. Valid parameters are
961 List reference of start date, end date, as UNIX timestamps.
971 List reference of charged limits (exclusive).
975 List reference of charged limits (exclusive).
979 flag, return open invoices only
983 flag, return net invoices only
991 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
995 sub search_sql_where {
996 my($class, $param) = @_;
998 # warn "$me search_sql_where called with params: \n".
999 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
1005 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
1006 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
1010 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
1011 # push @search, "cust_main.refnum = $1";
1015 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
1016 push @search, "quotation.prospectnum = $1";
1020 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
1021 push @search, "cust_bill.custnum = $1";
1025 if ( $param->{_date} ) {
1026 my($beginning, $ending) = @{$param->{_date}};
1028 push @search, "quotation._date >= $beginning",
1029 "quotation._date < $ending";
1033 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
1034 push @search, "quotation.quotationnum >= $1";
1036 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
1037 push @search, "quotation.quotationnum <= $1";
1041 # if ( $param->{charged} ) {
1042 # my @charged = ref($param->{charged})
1043 # ? @{ $param->{charged} }
1044 # : ($param->{charged});
1046 # push @search, map { s/^charged/cust_bill.charged/; $_; }
1050 my $owed_sql = FS::cust_bill->owed_sql;
1053 push @search, "quotation._date < ". (time-86400*$param->{'days'})
1054 if $param->{'days'};
1056 #agent virtualization
1057 my $curuser = $FS::CurrentUser::CurrentUser;
1058 #false laziness w/search/quotation.html
1059 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
1060 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
1063 join(' AND ', @search );
1069 Return line item hashes for each package on this quotation.
1074 my ($self, %options) = @_;
1075 my $escape = $options{'escape_function'};
1076 my $locale = $self->cust_or_prospect->locale;
1078 my $preref = $options{'preref_callback'};
1080 my $section = $options{'section'};
1081 my $freq = $section->{'category'};
1082 my @pkgs = $self->quotation_pkg;
1084 die "_items_pkg called without section->{'category'}"
1085 unless defined $freq;
1087 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
1088 # like we should have done in the first place
1090 foreach my $quotation_pkg (@pkgs) {
1091 my $part_pkg = $quotation_pkg->part_pkg;
1092 my @details = $quotation_pkg->details;
1095 'pkgnum' => $quotation_pkg->quotationpkgnum,
1096 'description' => $quotation_pkg->desc($locale),
1097 'ext_description' => \@details,
1098 'quantity' => $quotation_pkg->quantity,
1102 $setuprecur = 'setup';
1103 if ($part_pkg->freq ne '0') {
1104 # indicate that it's a setup fee on a recur package (cust_bill does
1106 $this_item->{'description'} .= ' Setup';
1109 # recur for this frequency
1110 next if $freq ne $part_pkg->freq;
1111 $setuprecur = 'recur';
1114 $this_item->{'unit_amount'} = sprintf('%.2f',
1115 $quotation_pkg->get('unit'.$setuprecur));
1116 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
1117 * $quotation_pkg->quantity);
1118 next if $this_item->{'amount'} == 0 and !(
1119 $setuprecur eq 'setup'
1120 ? $quotation_pkg->setup_show_zero
1121 : $quotation_pkg->recur_show_zero
1125 $this_item->{'preref_html'} = &$preref($quotation_pkg);
1128 push @items, $this_item;
1129 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1131 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1132 push @items, $discount;
1135 # each quotation_pkg_tax has two amounts: the amount charged on the
1136 # setup invoice, and the amount on the recurring invoice.
1137 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1138 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1140 'description' => $qpt->itemdesc,
1141 'ext_description' => [],
1144 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1146 } # foreach $quotation_pkg
1148 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1149 my $this_tax = $tax_item{$taxname};
1150 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1151 next if $this_tax->{'amount'} == 0;
1152 push @items, $this_tax;
1168 L<FS::Record>, schema.html from the base documentation.